UNPKG

@dnb/eufemia

Version:

DNB Eufemia Design System UI Library

1,690 lines (1,568 loc) 69.4 kB
--- title: 'Filter' description: 'Use Filter to help people narrow down a list or data set.' version: 11.8.2 generatedAt: 2026-07-03T14:39:18.708Z checksum: d880adfaa9ce2c1b007fb153044b524a0cf8f15772be0b91997fce5008d24928 --- # Filter ## Import ```tsx import { Filter } from '@dnb/eufemia' ``` ## Description `Filter` is a composable filter UI for building search and filter experiences. It does **not** own your data — instead, it provides shared state that you read with the `Filter.useFilter(id)` hook and apply to your own data source. The component uses a namespace pattern where `Filter` is the import and `Filter.Root` is the renderable root. ### Behavior By default, `Filter.Root` emits changes in real time via `onChange`. Set `behavior="manual"` to buffer filter changes internally — the panel will show an "Apply filter" button to commit changes and a "Cancel" button to revert changes. This is useful when filter changes trigger expensive operations like API calls. Note that search input is always emitted in real time, even in manual mode. `Filter.ActiveFilters` only shows applied filters, so tags won't appear until the user applies them. ### Filter keys Each filter is identified by a `filterKey` string. For `Filter.Selection` and `Filter.MultiSelection`, individual selected values are stored as `{filterKey}/{value}` entries in the state (e.g. `/status/active`, `/status/inactive`). This convention lets you inspect which values are selected by filtering the keys that start with the filter's prefix: ```ts const selectedStatuses = Object.keys(filters) .filter((key) => key.startsWith('/status/')) .map((key) => key.replace('/status/', '')) ``` The leading `/` is a convention to namespace filter keys — it is not a URL path or an Eufemia Forms JSON Pointer. You can use any string as a `filterKey`, but we recommend starting with `/` for consistency. When building custom filters with `Filter.useFilterContext()`, you can use any key format — the `{filterKey}/{value}` pattern is only used by the built-in selection components. ### Combining search and filters The Filter component stores `search` and `filters`, but does not decide how your data should match them. As a rule of thumb, each active filter should narrow the result set. Different filter groups are also usually combined with **AND**, such as status and region. Multiple values inside the same filter group usually behave as **OR**. For example, selecting two statuses means the item can match status active or inactive. Search can also match several fields with **OR**, such as name or amount. For custom filters and quick filters, choose the logic that matches the meaning of the controls. Use **OR** when the buttons are alternatives in the same category, and **AND** when they represent independent conditions that should all be true. ### Layout The Filter component can be used in two layout patterns: - **Inside a list** — Place `Filter.Root` inside the first `List.Item.Basic`. Filtered results render as subsequent list items. This is the most common pattern. - **Column layout with Grid** — Use `Grid.Container` with `Grid.Item` to place the filter and results side by side. Use `Filter.Content` to link the results area to the filter via `connectedTo`. ### Sub-components - **`Filter.Root`** — Root wrapper. Provides filter context and syncs state via `useSharedState`. Requires a unique `id`. Supports spacing props. - **`Filter.Header`** — Groups the filter controls (toolbar, panel, active filters) above the results. When used together with `Filter.Content` containing a `List.Container`, the header receives a subtle background and top border-radius to visually connect with the list below. - **`Filter.Search`** — Text input with a loupe icon. Updates the shared `search` string. Browser autocomplete, autocorrect, autocapitalize, and spellcheck are disabled by default. - **`Filter.Toolbar`** — Horizontal row that wraps `Filter.Search` and `Filter.Toolbar.Actions`. - **`Filter.Toolbar.Actions`** — Groups action buttons (e.g. `Filter.PanelButton`) for proper responsiveness. - **`Filter.Panel`** — Expandable inline panel toggled by `Filter.PanelButton`. Renders filter children as tertiary accordions with a white background. - **`Filter.PanelButton`** — Toggle button for `Filter.Panel`. Shows a filter icon when closed and a close icon when open. Accepts all `Button` props. - **`Filter.ActiveFilters`** — Renders active filters as removable `Tag` chips. Returns nothing when no filters are active. Set `showCategoryLabel` to prefix each tag with its category name (e.g. "Betalingstype: Kort" instead of "Kort"). Set `collapsibleThreshold` to collapse the tags behind a tertiary accordion with a scrollable area and a "Clear all" button when the number of active filters exceeds the threshold. In `behavior="manual"` mode, only applied filters are shown — draft changes in the panel won't appear as tags until the user clicks Apply. - **`Filter.Item`** — Accordion wrapper for a single filter section. Supports `defaultOpen` to start expanded. Open/closed state is remembered across panel opens. - **`Filter.Date`** — Built-in date range filter using `DatePicker`. When placed inside a `Filter.Panel`, it renders as an accordion with an inline calendar on larger screens. On small screens, it skips the accordion and renders as a tertiary trigger button that opens a calendar popover. When placed outside the panel, it always renders as a trigger button. - **`Filter.Selection`** — Built-in checkbox selection filter. Each selected option creates its own active filter tag. - **`Filter.MultiSelection`** — Built-in multi-selection filter using the Forms `MultiSelection` component. Each selected item creates its own active filter tag. - **`Filter.SortButton`** — Sort dropdown styled as a tertiary button with a sort icon. The trigger always displays the translated "Sort" label regardless of the selected option. - **`Filter.QuickFilters`** — Wrapper for quick filter toggle buttons placed outside the panel. Renders as a horizontal flex row with wrapping. - **`Filter.Highlighting`** — Highlights matching search text within result items. Reads the `search` string from the linked filter state and wraps matching substrings in `<mark>` tags. Can be linked via `connectedTo` or inherits the id from the nearest `Filter.Root` or `Filter.Content`. - **`Filter.Content`** — Wraps result content and shows a `Skeleton` loading state when the filter is loading. When used inside a `Filter.Root`, the id is inherited automatically. When used outside, link it via `connectedTo`. Supports spacing props. - **`Filter.NoResults`** — Renders a translated "no results" message when `resultCount` is `0`. When placed inside a `List.Container`, it automatically renders as a list item. Can be placed after `Filter.ActiveFilters` inside a container, or inside a `Filter.Content` where it inherits `connectedTo` automatically. - **`Filter.ResultCount`** — Displays the current result count as a translated message (e.g. "3 result(s)") when filters are active. Hidden when no filters or search text are applied. Reads `resultCount` from the nearest `Filter.Root`, a `connectedTo` id, or a `resultCount` prop. Supports spacing props. ### Hooks - **`Filter.useFilter(id)`** — Reads filter state from anywhere — does not need to be inside `Filter.Root`. Returns `{ filters, search, hasActiveFilters, resetFilters, removeFilter }`. - **`Filter.useFilterAsync(id, fetcher, options?)`** — Async data fetching linked to a filter. Handles loading state, race conditions, and syncs `resultLoading`/`resultCount` to shared state. Options: `initialData` for immediate rendering, `debounce` (ms) to delay fetcher calls while the user is typing. Returns `{ data, loading, error }`. - **`Filter.useFilterContext()`** — Accesses the full filter context from inside `Filter.Root`. Use this to build custom filter types. Returns `{ setFilter, getFilter, removeFilter, resetFilters, commitFilters, revertFilters, filters, search, hasActiveFilters }`. ### URL sync hooks These hooks sync filter state to URL query parameters so filters survive page reloads and browser navigation. Each hook writes `{id}-search` and `{id}-filters` query parameters. Pass `excludeSearch: true` to skip syncing the search string. - **`Filter.useQueryLocator(id, options?)`** — Uses the History API directly. Works without any router. Best for plain React apps or when no router is available. - **`Filter.useReactRouter(id, { useSearchParams, ...options })`** — Uses React Router's `useSearchParams`. Pass the hook from your router version. - **`Filter.useNextRouter(id, { useRouter, usePathname, useSearchParams, ...options })`** — Uses Next.js App Router hooks. Pass `useRouter`, `usePathname`, and `useSearchParams` from `next/navigation`. ## Basic usage ```tsx import { Filter, List } from '@dnb/eufemia' function MyPage() { const { filters, search, hasActiveFilters } = Filter.useFilter('my-filter') const filtered = myData.filter((item) => { if (search && !item.name.includes(search)) { return false } return true }) return ( <List.Container> <List.Item.Basic> <Filter.Root id="my-filter" resultCount={filtered.length}> <Filter.Toolbar> <Filter.Search label="Søk" placeholder="Søk ..." /> <Filter.Toolbar.Actions> <Filter.Date /> <Filter.PanelButton /> </Filter.Toolbar.Actions> </Filter.Toolbar> <Filter.Panel> <Filter.Selection label="Status" filterKey="/status" data={[ { value: 'active', label: 'Aktiv' }, { value: 'inactive', label: 'Inaktiv' }, ]} /> </Filter.Panel> <Filter.ActiveFilters /> </Filter.Root> </List.Item.Basic> <Filter.NoResults /> {filtered.map((item) => ( <List.Item.Basic key={item.id} title={item.name} /> ))} </List.Container> ) } ``` ## Decoupled hook usage `Filter.useFilter(id)` can be called from a completely separate component tree. The filter UI and the data consumer are linked only by the shared `id`: ```tsx function FilterUI() { return ( <Filter.Root id="transactions"> <Filter.Toolbar> <Filter.Search label="Søk" /> <Filter.Toolbar.Actions> <Filter.PanelButton /> </Filter.Toolbar.Actions> </Filter.Toolbar> <Filter.Panel> <Filter.Selection label="Type" filterKey="/type" data={[ { value: 'card', label: 'Kort' }, { value: 'transfer', label: 'Overføring' }, ]} /> </Filter.Panel> <Filter.ActiveFilters /> <Filter.NoResults /> </Filter.Root> ) } function TransactionList() { const { search, filters, hasActiveFilters } = Filter.useFilter('transactions') // Use search/filters to filter your data } ``` ## Custom filters Create custom filter types using `Filter.useFilterContext()` and `Filter.Item`: ```tsx function AmountRangeFilter({ label, filterKey }) { const { setFilter, getFilter } = Filter.useFilterContext() const current = getFilter(filterKey) const handleChange = (min, max) => { if (min == null && max == null) { setFilter(filterKey, undefined) } else { setFilter(filterKey, { value: { min, max }, label: `${label}: ${min ?? ''}–${max ?? ''}`, }) } } return ( <Filter.Item label={label} filterKey={filterKey}> <Flex.Horizontal> <Input label="Min" onChange={({ value }) => handleChange(value, current?.value?.max) } /> <Input label="Max" onChange={({ value }) => handleChange(current?.value?.min, value) } /> </Flex.Horizontal> </Filter.Item> ) } // Usage inside Filter.Panel: render( <Filter.Panel> <AmountRangeFilter label="Beløp" filterKey="/amount" /> </Filter.Panel> ) ``` ## Async data fetching `Filter.useFilterAsync(id, fetcher)` handles the full fetch lifecycle — loading state, race conditions, and result count — so you don't have to wire it up yourself. It calls your `fetcher` whenever the linked filter state changes and syncs `resultLoading` and `resultCount` to the shared state. That means `Filter.Content` and `Filter.NoResults` react automatically. ```tsx function TransactionList() { const { data } = Filter.useFilterAsync( 'my-filter', async ({ filters, search }) => { const res = await fetch(`/api/transactions?q=${search}`) return res.json() }, { initialData: [] } ) return ( <Filter.Content connectedTo="my-filter"> {data.map((tx) => ( <p key={tx.id}>{tx.name}</p> ))} </Filter.Content> ) } ``` The hook returns `{ data, loading, error }`. If the fetcher returns an array, `resultCount` is set to its length automatically. Pass `initialData` to render immediately before the first fetch resolves. Use the `debounce` option (in milliseconds) to delay the fetcher while the user is still typing. The initial fetch always runs immediately. ```tsx const { data } = Filter.useFilterAsync('my-filter', fetcher, { initialData: [], debounce: 300, }) ``` ## Accessibility The Filter component includes several accessibility features out of the box: ### Live announcements `Filter.Content` uses an `aria-live` region to announce filter result changes to screen readers: - When the result count changes, it announces the number of results (e.g. "3 treff"). - When no results are found (`resultCount={0}`), it announces the no-results message. - During loading, announcements are suppressed to avoid noisy updates. ### Focus management When the filter panel is closed — via the "Hide filter" button, the "Apply" button, or the "Cancel" button in manual mode — focus is automatically returned to the `Filter.PanelButton`. This ensures keyboard users don't lose their place in the page. ### ARIA attributes - `Filter.Root` renders with `role="search"` and an `aria-label` to identify the filter region. - `Filter.PanelButton` uses `aria-expanded` to communicate whether the panel is open or closed. - `Filter.ActiveFilters` renders a labeled group so screen readers can identify the active filter tags. ## Relevant links - [Figma](https://www.figma.com/design/cdtwQD8IJ7pTeE45U148r1/%F0%9F%92%BB-Eufemia---Web?node-id=15807-0) - [Source code](https://github.com/dnbexperience/eufemia/tree/main/packages/dnb-eufemia/src/components/filter) - [Docs code](https://github.com/dnbexperience/eufemia/tree/main/packages/dnb-design-system-portal/src/docs/uilib/components/filter) ## Related components Filter is part of the [Input](/uilib/components/overview/#input) category. Other components for similar needs: - [Autocomplete](/uilib/components/autocomplete/) – to help people find and choose from matching suggestions as they type. - [Checkbox](/uilib/components/checkbox/) – when people can turn one or more options on or off. - [DatePicker](/uilib/components/date-picker/) – when people need to choose one date or a date range. - [Dropdown](/uilib/components/dropdown/) – when people need to choose one option from a list. - [FormLabel](/uilib/components/form-label/) – to name an input, control, or form-related field. - [Input](/uilib/components/input/) – when people need to enter a short line of text. [See all in Input](/uilib/components/overview/#input) ## Demos ### Basic usage Combines `Filter.Date` and `Filter.Selection` inside `Filter.Panel`, with search, toolbar tools, and `resultCount` for the number of matching transactions. Uses the list layout pattern. ```tsx const Example = () => { const transactions = [ { id: 1, name: 'Rema 1000', amount: -245, type: 'card', }, { id: 2, name: 'DNB Salary', amount: 25000, type: 'transfer', }, { id: 3, name: 'Elkjøp', amount: -3999, type: 'card', }, ] const { filters, search } = Filter.useFilter('date-selection-demo') const filtered = transactions.filter((tx) => { if ( search && !tx.name.toLowerCase().includes(search.toLowerCase()) && !String(tx.amount).includes(search) ) { return false } const selectedTypes = Object.keys(filters) .filter((key) => key.startsWith('/type/')) .map((key) => key.replace('/type/', '')) if (selectedTypes.length > 0 && !selectedTypes.includes(tx.type)) { return false } return true }) return ( <> <Filter.Root id="date-selection-demo" resultCount={filtered.length}> <Filter.Header> <Filter.Toolbar> <Filter.Search label="Label" placeholder="Store name, amount..." /> <Filter.Toolbar.Actions> <Button variant="tertiary" icon={downloadIcon} iconPosition="left" > Download </Button> <Filter.PanelButton /> </Filter.Toolbar.Actions> </Filter.Toolbar> <Filter.Panel> <Filter.Date /> <Filter.Selection label="Payment type" filterKey="/type" data={[ { value: 'card', label: 'Card', }, { value: 'transfer', label: 'Transfer', }, ]} /> </Filter.Panel> <Filter.ActiveFilters /> <Filter.ResultCount /> </Filter.Header> </Filter.Root> <Filter.Content connectedTo="date-selection-demo"> <List.Container> <Filter.NoResults /> {filtered.map((tx) => ( <List.Item.Basic key={tx.id} title={<Filter.Highlighting>{tx.name}</Filter.Highlighting>} > <List.Cell.End> <Value.Currency value={tx.amount} /> </List.Cell.End> </List.Item.Basic> ))} </List.Container> </Filter.Content> </> ) } render(<Example />) ``` ### Custom filter type Build your own filter using `Filter.useFilterContext()` and `Filter.Item`. This example shows a toggle filter alongside the built-in `Filter.Selection`. ```tsx function ToggleFilter({ label, filterKey }) { const { setFilter, getFilter } = Filter.useFilterContext() const isActive = !!getFilter(filterKey) return ( <Filter.Item label={label} filterKey={filterKey}> <ToggleButton checked={isActive} onChange={({ checked }) => { if (checked) { setFilter(filterKey, { value: true, label, }) } else { setFilter(filterKey, undefined) } }} > {label} </ToggleButton> </Filter.Item> ) } const Example = () => { const places = [ { id: 1, name: 'Olivia Restaurant', category: 'restaurant', favorite: true, }, { id: 2, name: 'Grand Hotel', category: 'hotel', favorite: false, }, { id: 3, name: 'Kaffebrenneriet', category: 'cafe', favorite: true, }, { id: 4, name: 'Maaemo', category: 'restaurant', favorite: false, }, ] const { filters, search } = Filter.useFilter('custom-demo') const selectedCategories = Object.keys(filters) .filter((key) => key.startsWith('/category/')) .map((key) => key.replace('/category/', '')) const favoritesOnly = !!filters['/favorites'] const filtered = places.filter((place) => { if ( search && !place.name.toLowerCase().includes(search.toLowerCase()) ) { return false } if ( selectedCategories.length > 0 && !selectedCategories.includes(place.category) ) { return false } if (favoritesOnly && !place.favorite) { return false } return true }) return ( <> <Filter.Root id="custom-demo" resultCount={filtered.length}> <Filter.Header> <Filter.Toolbar> <Filter.Search label="Search" placeholder="Search..." /> <Filter.Toolbar.Actions> <Filter.PanelButton /> </Filter.Toolbar.Actions> </Filter.Toolbar> <Filter.Panel> <Filter.Selection label="Category" filterKey="/category" data={[ { value: 'restaurant', label: 'Restaurant', }, { value: 'hotel', label: 'Hotel', }, { value: 'cafe', label: 'Cafe', }, ]} /> <ToggleFilter label="Favorites only" filterKey="/favorites" /> </Filter.Panel> <Filter.ActiveFilters /> <Filter.ResultCount /> </Filter.Header> </Filter.Root> <Filter.Content connectedTo="custom-demo"> <List.Container> <Filter.NoResults /> {filtered.map((place) => ( <List.Item.Basic key={place.id} title={ <Filter.Highlighting>{place.name}</Filter.Highlighting> } /> ))} </List.Container> </Filter.Content> </> ) } render(<Example />) ``` ### Async result count When the result count comes from an API, use `resultLoading` to show a loading state while the request is in progress. Open the filter panel and change a filter to see the skeleton effect. This example uses `debounce: 300` to delay the API call while the user is typing. ```tsx const allTransactions = [ { id: 1, name: 'Rema 1000', amount: -245, status: 'active', }, { id: 2, name: 'DNB Salary', amount: 25000, status: 'active', }, { id: 3, name: 'Elkjøp', amount: -3999, status: 'inactive', }, { id: 4, name: 'Kiwi', amount: -189, status: 'active', }, { id: 5, name: 'Spotify', amount: -119, status: 'inactive', }, ] // Simulates an API call with a delay // Simulates an API call with a delay function fetchFiltered(filters, search) { return new Promise<typeof allTransactions>((resolve) => { setTimeout(() => { const result = allTransactions.filter((tx) => { if ( search && !tx.name.toLowerCase().includes(search.toLowerCase()) && !String(tx.amount).includes(search) ) { return false } const selectedStatuses = Object.keys(filters) .filter((key) => key.startsWith('/status/')) .map((key) => key.replace('/status/', '')) if ( selectedStatuses.length > 0 && !selectedStatuses.includes(tx.status) ) { return false } return true }) resolve(result) }, 1000) }) } const Example = () => { const { data: filtered } = Filter.useFilterAsync( 'async-demo', ({ filters, search }) => fetchFiltered(filters, search), { initialData: allTransactions, debounce: 300, } ) return ( <> <Filter.Root id="async-demo"> <Filter.Header> <Filter.Toolbar> <Filter.Search label="Search" placeholder="Search for something..." /> <Filter.Toolbar.Actions> <Filter.Date /> <Filter.PanelButton /> </Filter.Toolbar.Actions> </Filter.Toolbar> <Filter.Panel> <Filter.Selection label="Status" filterKey="/status" defaultOpen data={[ { value: 'active', label: 'Active', }, { value: 'inactive', label: 'Inactive', }, ]} /> </Filter.Panel> <Filter.ActiveFilters /> <Filter.ResultCount /> </Filter.Header> </Filter.Root> <Filter.Content connectedTo="async-demo"> <List.Container> <Filter.NoResults /> {filtered.map((tx) => ( <List.Item.Basic key={tx.id} title={<Filter.Highlighting>{tx.name}</Filter.Highlighting>} > <List.Cell.End> <Value.Currency value={tx.amount} /> </List.Cell.End> </List.Item.Basic> ))} </List.Container> </Filter.Content> </> ) } render(<Example />) ``` ### Manual behavior With `behavior="manual"`, panel filter changes are buffered internally and not emitted until the user clicks "Apply filter". Search input is still emitted in real time. The panel shows an Apply button and a Cancel button that reverts unsaved changes. ```tsx const allTransactions = [ { id: 1, name: 'Rema 1000', amount: -245, status: 'active', }, { id: 2, name: 'DNB Salary', amount: 25000, status: 'active', }, { id: 3, name: 'Elkjøp', amount: -3999, status: 'inactive', }, { id: 4, name: 'Kiwi', amount: -189, status: 'active', }, { id: 5, name: 'Spotify', amount: -119, status: 'inactive', }, ] function fetchFiltered(filters, search) { return new Promise<typeof allTransactions>((resolve) => { setTimeout(() => { const result = allTransactions.filter((tx) => { if ( search && !tx.name.toLowerCase().includes(search.toLowerCase()) && !String(tx.amount).includes(search) ) { return false } const selectedStatuses = Object.keys(filters) .filter((key) => key.startsWith('/status/')) .map((key) => key.replace('/status/', '')) if ( selectedStatuses.length > 0 && !selectedStatuses.includes(tx.status) ) { return false } return true }) resolve(result) }, 1000) }) } const Example = () => { const { data: filtered } = Filter.useFilterAsync( 'manual-demo', ({ filters, search }) => fetchFiltered(filters, search), { initialData: allTransactions, } ) return ( <> <Filter.Root id="manual-demo" behavior="manual"> <Filter.Header> <Filter.Toolbar> <Filter.Search label="Search" placeholder="Search for something..." /> <Filter.Toolbar.Actions> <Filter.PanelButton /> </Filter.Toolbar.Actions> </Filter.Toolbar> <Filter.Panel> <Filter.Selection label="Status" filterKey="/status" defaultOpen data={[ { value: 'active', label: 'Active', }, { value: 'inactive', label: 'Inactive', }, ]} /> </Filter.Panel> <Filter.ActiveFilters /> <Filter.ResultCount /> </Filter.Header> </Filter.Root> <Filter.Content connectedTo="manual-demo"> <List.Container> <Filter.NoResults /> {filtered.map((tx) => ( <List.Item.Basic key={tx.id} title={<Filter.Highlighting>{tx.name}</Filter.Highlighting>} > <List.Cell.End> <Value.Currency value={tx.amount} /> </List.Cell.End> </List.Item.Basic> ))} </List.Container> </Filter.Content> </> ) } render(<Example />) ``` ### Predefined filters Use `defaultFilters` to pre-select filters on mount. The panel and relevant filter accordions open automatically. ```tsx const Example = () => { const transactions = [ { id: 1, name: 'Rema 1000', amount: -245, type: 'card', }, { id: 2, name: 'DNB Salary', amount: 25000, type: 'transfer', }, { id: 3, name: 'Elkjøp', amount: -3999, type: 'card', }, ] const { filters, search } = Filter.useFilter('predefined-demo') const filtered = transactions.filter((tx) => { if ( search && !tx.name.toLowerCase().includes(search.toLowerCase()) && !String(tx.amount).includes(search) ) { return false } const selectedTypes = Object.keys(filters) .filter((key) => key.startsWith('/type/')) .map((key) => key.replace('/type/', '')) if (selectedTypes.length > 0 && !selectedTypes.includes(tx.type)) { return false } return true }) return ( <> <Filter.Root id="predefined-demo" resultCount={filtered.length} defaultFilters={{ '/type/card': { value: 'card', label: 'Card', categoryLabel: 'Payment type', }, '/type/transfer': { value: 'transfer', label: 'Transfer', categoryLabel: 'Payment type', }, '/status/pending': { value: 'pending', label: 'Pending', categoryLabel: 'Status', }, '/status/completed': { value: 'completed', label: 'Completed', categoryLabel: 'Status', }, '/status/failed': { value: 'failed', label: 'Failed', categoryLabel: 'Status', }, '/region/oslo': { value: 'oslo', label: 'Oslo', categoryLabel: 'Region', }, '/region/bergen': { value: 'bergen', label: 'Bergen', categoryLabel: 'Region', }, '/region/trondheim': { value: 'trondheim', label: 'Trondheim', categoryLabel: 'Region', }, '/category/groceries': { value: 'groceries', label: 'Groceries', categoryLabel: 'Category', }, '/category/electronics': { value: 'electronics', label: 'Electronics', categoryLabel: 'Category', }, '/category/salary': { value: 'salary', label: 'Salary', categoryLabel: 'Category', }, '/category/subscription': { value: 'subscription', label: 'Subscription', categoryLabel: 'Category', }, }} > <Filter.Header> <Filter.Toolbar> <Filter.Search label="Label" placeholder="Store name, amount..." /> <Filter.Toolbar.Actions> <Button variant="tertiary" icon={downloadIcon} iconPosition="left" > Download </Button> <Filter.PanelButton /> </Filter.Toolbar.Actions> </Filter.Toolbar> <Filter.Panel> <Filter.Date /> <Filter.Selection label="Payment type" filterKey="/type" data={[ { value: 'card', label: 'Card', }, { value: 'transfer', label: 'Transfer', }, ]} /> </Filter.Panel> <Filter.ActiveFilters showCategoryLabel collapsibleThreshold={5} /> <Filter.ResultCount /> </Filter.Header> </Filter.Root> <Filter.Content connectedTo="predefined-demo"> <List.Container> <Filter.NoResults /> {filtered.map((tx) => ( <List.Item.Basic key={tx.id} title={<Filter.Highlighting>{tx.name}</Filter.Highlighting>} > <List.Cell.End> <Value.Currency value={tx.amount} /> </List.Cell.End> </List.Item.Basic> ))} </List.Container> </Filter.Content> </> ) } render(<Example />) ``` ### URL sync with router hooks Three hooks sync filter state with URL query parameters so users can share or bookmark filtered views. Back/forward navigation restores the previous filter state. - **`Filter.useQueryLocator(id, options?)`** — Uses the History API directly. Works without any router dependency. Pass `{ excludeSearch: true }` to exclude the search string from URL sync. - **`Filter.useReactRouter(id, { useSearchParams, excludeSearch? })`** — Uses React Router's `useSearchParams`. - **`Filter.useNextRouter(id, { useRouter, usePathname, useSearchParams, excludeSearch? })`** — Uses Next.js navigation hooks. ```tsx const transactions = [ { id: 1, name: 'Rema 1000', amount: -245, status: 'active', }, { id: 2, name: 'DNB Salary', amount: 25000, status: 'active', }, { id: 3, name: 'Elkjøp', amount: -3999, status: 'inactive', }, { id: 4, name: 'Kiwi', amount: -189, status: 'active', }, ] const Example = () => { // Syncs filter state to/from URL query parameters Filter.useQueryLocator('query-locator-demo', { // excludeSearch: true, // You can exclude search from the URL if you want, by default it is included }) const { filters, search } = Filter.useFilter('query-locator-demo') const selectedStatuses = Object.keys(filters) .filter((key) => key.startsWith('/status/')) .map((key) => key.replace('/status/', '')) const filtered = transactions.filter((tx) => { if ( search && !tx.name.toLowerCase().includes(search.toLowerCase()) && !String(tx.amount).includes(search) ) { return false } if ( selectedStatuses.length > 0 && !selectedStatuses.includes(tx.status) ) { return false } return true }) return ( <> <Filter.Root id="query-locator-demo" resultCount={filtered.length}> <Filter.Header> <Filter.Toolbar> <Filter.Search label="Search" placeholder="Store name..." /> <Filter.Toolbar.Actions> <Filter.PanelButton /> </Filter.Toolbar.Actions> </Filter.Toolbar> <Filter.Panel> <Filter.Selection label="Status" filterKey="/status" data={[ { value: 'active', label: 'Active', }, { value: 'inactive', label: 'Inactive', }, ]} /> </Filter.Panel> <Filter.ActiveFilters /> <Filter.ResultCount /> </Filter.Header> </Filter.Root> <Filter.Content connectedTo="query-locator-demo"> <List.Container> <Filter.NoResults /> {filtered.map((tx) => ( <List.Item.Basic key={tx.id} title={<Filter.Highlighting>{tx.name}</Filter.Highlighting>} > <List.Cell.End> <Value.Currency value={tx.amount} /> </List.Cell.End> </List.Item.Basic> ))} </List.Container> </Filter.Content> </> ) } render(<Example />) ``` ### With sort button Use `Filter.SortButton` to add a sort dropdown to the toolbar. It renders a tertiary Dropdown with a sort icon and independent width. The sort state is managed outside the filter. ```tsx const Example = () => { const transactions = [ { id: 1, name: 'Rema 1000', amount: -245, }, { id: 2, name: 'DNB Salary', amount: 25000, }, { id: 3, name: 'Elkjøp', amount: -3999, }, { id: 4, name: 'Kiwi', amount: -189, }, ] const sortOptions = [ { selectedKey: 'newest', content: 'Newest first', }, { selectedKey: 'oldest', content: 'Oldest first', }, { selectedKey: 'amount-high', content: 'Amount high–low', }, { selectedKey: 'amount-low', content: 'Amount low–high', }, ] const [sortKey, setSortKey] = useState('newest') const { search } = Filter.useFilter('sort-demo') const filtered = transactions .filter((tx) => { if ( search && !tx.name.toLowerCase().includes(search.toLowerCase()) && !String(tx.amount).includes(search) ) { return false } return true }) .sort((a, b) => { switch (sortKey) { case 'oldest': return a.id - b.id case 'amount-high': return b.amount - a.amount case 'amount-low': return a.amount - b.amount default: return b.id - a.id } }) return ( <> <Filter.Root id="sort-demo" resultCount={filtered.length}> <Filter.Header> <Filter.Toolbar> <Filter.Search label="Search" placeholder="Store name..." /> <Filter.Toolbar.Actions> <Filter.SortButton data={sortOptions} value={sortKey} onChange={({ data: { selectedKey } }) => { setSortKey(String(selectedKey)) }} /> </Filter.Toolbar.Actions> </Filter.Toolbar> <Filter.ResultCount /> </Filter.Header> </Filter.Root> <Filter.Content connectedTo="sort-demo"> <List.Container> <Filter.NoResults /> {filtered.map((tx) => ( <List.Item.Basic key={tx.id} title={<Filter.Highlighting>{tx.name}</Filter.Highlighting>} > <List.Cell.End> <Value.Currency value={tx.amount} /> </List.Cell.End> </List.Item.Basic> ))} </List.Container> </Filter.Content> </> ) } render(<Example />) ``` ### Quick filters Quick filters are toggle buttons placed directly below the toolbar, outside the panel. They let users apply common filters without opening the panel. ```tsx function QuickFilter({ label, filterKey }) { const { setFilter, getFilter } = Filter.useFilterContext() const isActive = !!getFilter(filterKey) return ( <ToggleButton checked={isActive} onChange={({ checked }) => { if (checked) { setFilter(filterKey, { value: true, label, }) } else { setFilter(filterKey, undefined) } }} > {label} </ToggleButton> ) } const Example = () => { const transactions = [ { id: 1, name: 'Rema 1000', amount: -245, type: 'card', }, { id: 2, name: 'DNB Salary', amount: 25000, type: 'transfer', }, { id: 3, name: 'Elkjøp', amount: -3999, type: 'card', }, { id: 4, name: 'Kiwi', amount: -189, type: 'card', }, ] const { filters, search } = Filter.useFilter('quick-filters-demo') const showCards = !!filters['/card'] const showTransfers = !!filters['/transfer'] const filtered = transactions.filter((tx) => { if ( search && !tx.name.toLowerCase().includes(search.toLowerCase()) && !String(tx.amount).includes(search) ) { return false } if (showCards && tx.type !== 'card') { return false } if (showTransfers && tx.type !== 'transfer') { return false } return true }) return ( <> <Filter.Root id="quick-filters-demo" resultCount={filtered.length}> <Filter.Header> <Filter.QuickFilters> <QuickFilter label="Card" filterKey="/card" /> <QuickFilter label="Transfer" filterKey="/transfer" /> </Filter.QuickFilters> <Filter.ResultCount /> </Filter.Header> </Filter.Root> <Filter.Content connectedTo="quick-filters-demo"> <List.Container> <Filter.NoResults /> {filtered.map((tx) => ( <List.Item.Action key={tx.id} title={<Filter.Highlighting>{tx.name}</Filter.Highlighting>} > <List.Cell.End> <Value.Currency value={tx.amount} /> </List.Cell.End> </List.Item.Action> ))} </List.Container> </Filter.Content> </> ) } render(<Example />) ``` ### Toolbar with actions only A toolbar with only action buttons and no search field. ```tsx const Example = () => { const items = [ { id: 1, name: 'Report Q1', amount: 12000, }, { id: 2, name: 'Report Q2', amount: 15000, }, { id: 3, name: 'Report Q3', amount: 9800, }, ] return ( <> <Filter.Root id="actions-only-demo"> <Filter.Header> <Filter.Toolbar> <Filter.Toolbar.Actions> <Button variant="tertiary" icon={tableIcon} iconPosition="left" > Layout </Button> <Button variant="tertiary" icon={downloadIcon} iconPosition="left" > Download </Button> </Filter.Toolbar.Actions> </Filter.Toolbar> </Filter.Header> </Filter.Root> <Filter.Content connectedTo="actions-only-demo"> <List.Container> {items.map((item) => ( <List.Item.Action key={item.id} title={ <Filter.Highlighting>{item.name}</Filter.Highlighting> } > <List.Cell.End> <Value.Currency value={item.amount} /> </List.Cell.End> </List.Item.Action> ))} </List.Container> </Filter.Content> </> ) } render(<Example />) ``` ### Search only A simple search field with a secondary search button. ```tsx const Example = () => { const items = useMemo( () => [ { id: 1, name: 'Rema 1000', amount: -245, }, { id: 2, name: 'Kiwi', amount: -189, }, { id: 3, name: 'Salary', amount: 35000, }, ], [] ) const getFilteredItems = useCallback( (searchValue: string) => { return items.filter((item) => { if ( searchValue && !item.name.toLowerCase().includes(searchValue.toLowerCase()) && !String(item.amount).includes(searchValue) ) { return false } return true }) }, [items] ) const { search } = Filter.useFilter('search-only-demo') const previousSearchRef = useRef(search) const [showSkeleton, setShowSkeleton] = useState(false) const [filtered, setFiltered] = useState(() => getFilteredItems(search)) useEffect(() => { if (previousSearchRef.current === search) { return // stop here } previousSearchRef.current = search setShowSkeleton(true) const timeout = setTimeout(() => { setFiltered(getFilteredItems(search)) setShowSkeleton(false) }, 1000) return () => clearTimeout(timeout) }, [getFilteredItems, search]) const visibleItems = showSkeleton ? items : filtered return ( <> <Filter.Root id="search-only-demo" resultCount={filtered.length}> <Filter.Header> <Filter.Toolbar> <Filter.Search submitBehavior="manual" label="Search" placeholder="Search..." /> </Filter.Toolbar> <Filter.ResultCount /> </Filter.Header> </Filter.Root> <Filter.Content connectedTo="search-only-demo"> <List.Container skeleton={showSkeleton}> {!showSkeleton && <Filter.NoResults />} {visibleItems.map((item) => ( <List.Item.Action key={item.id} title={ <Filter.Highlighting>{item.name}</Filter.Highlighting> } > <List.Cell.End> <Value.Currency value={item.amount} /> </List.Cell.End> </List.Item.Action> ))} </List.Container> </Filter.Content> </> ) } render(<Example />) ``` ### Multi-selection filter with grid layout Use `Filter.MultiSelection` inside `Filter.Panel` to let users select one or more clients. This example uses a `Grid` layout to place the filter and results side by side. ```tsx const clients = [ { value: 'acme', title: 'Acme Corp', }, { value: 'globex', title: 'Globex Inc', }, { value: 'initech', title: 'Initech Ltd', }, { value: 'umbrella', title: 'Umbrella Group', }, ] const transactions = [ { id: 1, name: 'Invoice #1012', amount: 45000, client: 'acme', }, { id: 2, name: 'Invoice #1013', amount: 12500, client: 'globex', }, { id: 3, name: 'Credit note #204', amount: -3200, client: 'acme', }, { id: 4, name: 'Invoice #1014', amount: 78000, client: 'initech', }, { id: 5, name: 'Invoice #1015', amount: 9400, client: 'umbrella', }, { id: 6, name: 'Invoice #1016', amount: 23000, client: 'globex', }, ] const Example = () => { const { filters, search } = Filter.useFilter('multi-selection-demo') const selectedClients = Object.keys(filters) .filter((key) => key.startsWith('/client/')) .map((key) => key.replace('/client/', '')) const filtered = transactions.filter((tx) => { if ( search && !tx.name.toLowerCase().includes(search.toLowerCase()) && !String(tx.amount).includes(search) ) { return false } if ( selectedClients.length > 0 && !selectedClients.includes(tx.client) ) { return false } return true }) return ( <Grid.Container columnGap="large" rowGap="large" style={{ marginInline: 'auto', maxInlineSize: 'var(--layout-medium)', }} > <Grid.Item span={{ small: 'full', large: [1, 4], }} > <Heading size="large" top={false}> Filter </Heading> <Filter.Root id="multi-selection-demo" resultCount={filtered.length} > <Filter.Toolbar> <Filter.Search label="Search" placeholder="Invoice number..." /> <Filter.Toolbar.Actions> <Filter.Date /> <Filter.PanelButton /> </Filter.Toolbar.Actions> </Filter.Toolbar> <Filter.Panel> <Filter.MultiSelection label="Client" filterKey="/client" data={clients} /> </Filter.Panel> <Filter.ActiveFilters /> <Filter.ResultCount /> </Filter.Root> </Grid.Item> <Grid.Item span={{ small: 'full', large: [5, 12], }} > <Filter.Content connectedTo="multi-selection-demo"> <Heading size="large" top={false}> Transactions </Heading> <List.Container> <Filter.NoResults /> {filtered.map((tx) => ( <List.Item.Basic key={tx.id} title={ <Filter.Highlighting>{tx.name}</Filter.Highlighting> } > <List.Cell.End> <Value.Currency value={tx.amount} /> </List.Cell.End> </List.Item.Basic> ))} </List.Container> </Filter.Content> </Grid.Item> </Grid.Container> ) } render(<Example />) ``` ### Decoupled hook `Filter.useFilter(id)` can be called anywhere in the tree — the filter UI and data consumer can live in completely separate components. ```tsx function TransactionList() { const { search } = Filter.useFilter('decoupled-demo') const data = [ { id: 1, name: 'Rema 1000', amount: -245, }, { id: 2, name: 'Kiwi', amount: -189, }, { id: 3, name: 'Salary', amount: 35000, }, ] const filtered = data.filter((item) => { if ( search && !item.name.toLowerCase().includes(search.toLowerCase()) && !String(item.amount).includes(search) ) { return false } return true }) return ( <Filter.Content connectedTo="decoupled-demo"> <P space> {filtered.length > 0 && <P>Antall: {filtered.length}</P>} </P> {filtered.length > 0 && ( <List.Container> <Filter.NoResults /> {filtered.map((item) => ( <List.Item.Basic key={item.id} title={ <Filter.Highlighting>{item.name}</Filter.Highlighting> } > <List.Cell.End> <Value.Currency value={item.amount} /> </List.