A pill that names a filter, opens its choices or a panel, and shows its value with a clear button.
One choice
A FilterChip with selectionMode="single" opens Select's list under the pill. The list gets a filter field at nine options or more. A press or Enter chooses and closes. With a value, the chip is the ink fill, shows its label and value, and is followed by a clear button. The list has no empty choice, as React Aria's single mode has none, so the clear button is the way back to none. It is a tab stop.
Example
Example
'use client';
import { FilterChip, PickerOption, type Key } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';
export default function FilterChipSingle(): ReactElement {
const [category, setCategory] = useState<Key | null>(null);
return (
<FilterChip label="Category" selectionMode="single" value={category} onChange={setCategory}>
<PickerOption id="groceries">Groceries</PickerOption>
<PickerOption id="rent">Rent</PickerOption>
<PickerOption id="transport">Transport</PickerOption>
<PickerOption id="utilities">Utilities</PickerOption>
<PickerOption id="dining">Dining out</PickerOption>
</FilterChip>
);
}Several choices
With selectionMode="multiple", a press, Enter or Space toggles a row and the list stays open. onChange receives every chosen id each time. The chip summarizes the chosen options' text in list order, whatever the order of the value: "Groceries", "Groceries, Rent", or "Groceries, Rent and 2 more". The default summary is English and uses no Intl. Pass labels.summary for another language, or valueLabel to write it yourself.
Example
Example
'use client';
import { FilterChip, PickerOption, type Key } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';
export default function FilterChipMultiple(): ReactElement {
const [tags, setTags] = useState<readonly Key[]>(['rent']);
return (
<FilterChip label="Tags" selectionMode="multiple" value={tags} onChange={setTags}>
<PickerOption id="groceries">Groceries</PickerOption>
<PickerOption id="rent">Rent</PickerOption>
<PickerOption id="transport">Transport</PickerOption>
<PickerOption id="utilities">Utilities</PickerOption>
<PickerOption id="dining">Dining out</PickerOption>
<PickerOption id="health">Health</PickerOption>
</FilterChip>
);
}Your own content
Without selectionMode, the chip opens a panel with 16px padding around your content, such as two money fields, a range calendar or a segmented control.
valueLabel: the value the chip shows. Without it, the chip is at rest.onClear: called by the clear button. Without it, there is no clear button.- A function child receives
close, for a Done or Apply button.
Example
Example
'use client';
import { Button, FilterChip, NumberField } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';
/** Whole dollars from minor units, written by the app with no Intl: 12500 is "125". */
const dollars = (cents: number): string => String(Math.trunc(cents / 100));
/** The app's own words for the range. */
function amountLabel(over: number | null, under: number | null): string | undefined {
if (over !== null && under !== null) return `$${dollars(over)} to $${dollars(under)}`;
if (over !== null) return `Over $${dollars(over)}`;
if (under !== null) return `Under $${dollars(under)}`;
return undefined;
}
export default function FilterChipCustom(): ReactElement {
const [over, setOver] = useState<number | null>(null);
const [under, setUnder] = useState<number | null>(null);
return (
<FilterChip
label="Amount"
valueLabel={amountLabel(over, under)}
onClear={() => {
setOver(null);
setUnder(null);
}}
>
{({ close }) => (
<div className="stack">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<NumberField label="Over" money={{ symbol: '$' }} value={over} onChange={setOver} />
<NumberField label="Under" money={{ symbol: '$' }} value={under} onChange={setUnder} />
</div>
<Button variant="secondary" onPress={close}>
Done
</Button>
</div>
)}
</FilterChip>
);
}States
- At rest: a boundary edge with no fill, the label at 500 and a slate chevron.
- Active: the ink fill, the label at 450 and the value at 700.
- Disabled: taupe on a faint edge. It keeps its value on the disabled fill and has no clear button.
A filter is neither consent nor unsaved, so a chip is never signal orange and never clay.
Example
Example
'use client';
import { FilterChip, PickerOption, type Key } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';
const CATEGORIES = (
<>
<PickerOption id="groceries">Groceries</PickerOption>
<PickerOption id="rent">Rent</PickerOption>
<PickerOption id="transport">Transport</PickerOption>
</>
);
export default function FilterChipStates(): ReactElement {
const [category, setCategory] = useState<Key | null>(null);
const [account, setAccount] = useState<Key | null>('rent');
return (
// The row is the page's width, and never held wider by a chip, so a long value ellipsizes inside it.
<div className="row" style={{ width: '100%', minWidth: 0 }}>
<FilterChip label="Category" selectionMode="single" value={category} onChange={setCategory}>
{CATEGORIES}
</FilterChip>
<FilterChip label="Budget" selectionMode="single" value={account} onChange={setAccount}>
{CATEGORIES}
</FilterChip>
<FilterChip label="Merchant" selectionMode="single" value={null} onChange={() => undefined} isDisabled>
{CATEGORIES}
</FilterChip>
<FilterChip
label="Account"
selectionMode="single"
value="groceries"
onChange={() => undefined}
isDisabled
>
{CATEGORIES}
</FilterChip>
</div>
);
}Usage
A chip is always controlled. Its value, a key or a list of keys, belongs in the URL, and a FilterBar draws each chip twice. Read the value from the search parameters and write it back with the router:
- Start from the current parameters so the other filters stay.
- Delete the parameter for no value, and never write a bare
?. - Pass
{ scroll: false }so the page does not jump.
Show a mirror of the value in your own state. A write goes through the router before the new parameters arrive, and until then the chip would show its old value. seen holds the value it last read or wrote, and the mirror reloads from the URL only when it changes under the chip, by Back, Forward or a link:
'use client';
import { FilterChip, PickerOption, type Key } from '@snapshot-ui/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
export function CategoryFilter() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const category = searchParams.get('category');
const [value, setValue] = useState<Key | null>(category);
const [seen, setSeen] = useState(category);
// The URL changed under the chip, by Back, Forward or a link: show its category.
if (category !== seen) {
setSeen(category);
setValue(category);
}
return (
<FilterChip
label="Category"
selectionMode="single"
value={value}
onChange={(next: Key | null) => {
setValue(next);
setSeen(next === null ? null : String(next));
const params = new URLSearchParams(searchParams);
if (next === null) params.delete('category');
else params.set('category', String(next));
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}}
>
<PickerOption id="groceries">Groceries</PickerOption>
<PickerOption id="rent">Rent</PickerOption>
</FilterChip>
);
}- Multiple chips: write one parameter per key with
params.delete('tag'), thenappendeach key. Read them back withsearchParams.getAll('tag'), and use the whole query asseen. - Several chips share one mirror and one
seen, as the FilterBar recipe shows. - On a statically rendered route, read the parameters inside
<Suspense>, with the same chip, empty, as the fallback.
A closed chip ignores letters and Left and Right. A Select turns those into a choice nobody sees, which on a filter would be a query and a URL write behind the reader's back. The popover is modal, so a press on the next chip first closes this one. Inside a FilterBar, a chip is also a titled section of More and of the Filters sheet.
Props
A chip with a list, a chip with your content, then its words. A chip takes no name and submits nothing, since its value is in the URL.
className string | Goes on the pill, or on the section where a FilterBar draws one, after the library's class. |
|---|---|
isDisabled boolean | |
isFilterable boolean | A filter field at the top of the list. Default: true at 9 options or more, Select's rule |
isSecondary boolean | Starts behind "More" in a FilterBar, and joins the row once it has a value. |
items Iterable<object> | The options as data, rendered by a function child. Omit it for static |
label string | The filter's name, always shown: "Category". It names the chip, its panel and its section. |
labels Partial<FilterChipLabels> | The library's own words, in English unless replaced. |
onChange ((value: Key | null) => void) | ((value: Key[]) => void) | |
onClear (() => void) | Clears the value. A list chip clears through |
placement Placement | Where the list or the panel opens. Default: 'bottom start' |
popoverClassName string | Goes on the popover around the list or the panel. |
selectionMode "multiple" | "single" | One choice: the list closes on it, and the clear button is the way back to none. Several choices: the list stays open as each one toggles. |
value readonly Key[] | Key | null | The chosen option's id, or null for none. The chosen options' ids; an empty array for none. |
valueLabel string | The value as the chip shows it. A custom chip needs it to show a value; a list chip computes one. |
children ((opts: { close: () => void; }) => ReactNode) | ReactNode | Free content: a RangeCalendar, two NumberFields, a SegmentedControl. A function receives |
|---|---|
className string | Goes on the pill, or on the section where a FilterBar draws one, after the library's class. |
isDisabled boolean | |
isSecondary boolean | Starts behind "More" in a FilterBar, and joins the row once it has a value. |
label string | The filter's name, always shown: "Category". It names the chip, its panel and its section. |
onClear (() => void) | Clears the value. A list chip clears through |
popoverClassName string | Goes on the popover around the list or the panel. |
valueLabel string | The value as the chip shows it. A custom chip needs it to show a value; a list chip computes one. |
any string | A list chip's placeholder where it draws as a section, in More or the Filters sheet. Default: 'Any' |
|---|---|
clear (label: string) => string | The clear button's name, given the chip's label. In More and the Filters sheet, the button's visible word
is FilterBar's Default: (label) => `Clear ${label}` |
empty string | Select's: the line shown when the filter keeps nothing. Default: 'No matches' |
filter string | Select's: the filter's name. Default: 'Filter' |
summary (texts: readonly string[]) => string | A list chip's value, given the chosen options' text values in list order. Default: "Food", "Food, Rent", "Food, Rent and 2 more" |
Accessibility
A list chip is a button with aria-haspopup="listbox", named by its label and then its value, as shown: "Category Groceries". Its list is named by the label. A chip with your content is a button that opens a dialog named the same way. The clear button is named "Clear" plus the label, from labels.clear, and is a tab stop after the chip. The popover is modal: focus moves into it, the page behind is inert, and focus returns to the chip on close. React Aria's own announcements have no Persian.
| EnterSpace | Open the list or the panel; on the clear button, clear, and focus goes to the chip. |
|---|---|
| DownUp | Open the list; a panel opens on Enter and Space only. |
| A-ZLeftRight | Do nothing on a closed chip, which keeps them from choosing unseen. |
| DownUpHomeEndPageUpPageDown | Move through the open list, stopping at the ends. |
| A-Z | In an open list, move to the option starting with the letters typed; with a filter, type into it. |
| Enter | Chooses and closes with one choice; toggles and stays open with several, as Space does. |
| Escape | Clears the list filter first, then closes; closes a panel. Focus goes back to the chip. |
| Tab | Does not close an open list; in a panel, moves through its content; from the chip, to its clear button. |
Design rules
What keeps filter chips in the system.
Give each filter a short label.
Write the summary in the language of the app.
Use a filter chip for a filter that writes the URL.
Never draw an active chip in signal orange or clay.
Never leave a chip without a clear way back to no value.
Never let a letter on a closed chip choose a filter.