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

Follows the page theme and corners
'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

Follows the page theme and corners
'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

Follows the page theme and corners
'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

Follows the page theme and corners
'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'), then append each key. Read them back with searchParams.getAll('tag'), and use the whole query as seen.
  • 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 PickerOption children.

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 onChange without it; a custom chip needs it.

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 close.

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 onChange without it; a custom chip needs it.

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 labels.clear, so a localized name must start with that word.

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.

EnterSpaceOpen the list or the panel; on the clear button, clear, and focus goes to the chip.
DownUpOpen the list; a panel opens on Enter and Space only.
A-ZLeftRightDo nothing on a closed chip, which keeps them from choosing unseen.
DownUpHomeEndPageUpPageDownMove through the open list, stopping at the ends.
A-ZIn an open list, move to the option starting with the letters typed; with a filter, type into it.
EnterChooses and closes with one choice; toggles and stays open with several, as Space does.
EscapeClears the list filter first, then closes; closes a panel. Focus goes back to the chip.
TabDoes 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.

esc

↑ ↓ to move↵ to open