@dnb/eufemia
Version:
DNB Eufemia Design System UI Library
1,690 lines (1,568 loc) • 69.4 kB
Markdown
---
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.