The filters of a list page: a search and a row of filter chips, with one Filters sheet on a phone.

Basic

A FilterBar shows your search first, then its FilterChips in a group named "Filters" that wraps when it runs out of room. Nothing is measured.

  • Secondary chips: a chip with isSecondary and no value starts behind More, as a titled section of its panel. More is not drawn when no secondary chip is inactive.
  • Active chips: a chip with a value is always in the row, after the primary chips, so every active filter shows its clear button.
  • Clear all: with onClearAll, it sits at the end of the row while any filter is active.

Example

Follows the page theme and corners
'use client';

import {
  FilterBar,
  FilterChip,
  PickerOption,
  RangeCalendar,
  SearchField,
  type DateRange,
  type Key,
} from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

/** The app's own month names: the library writes no date, and the app writes this one with no Intl. */
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];

/** "1 Sep to 30 Sep" from two ISO dates, in the app's words. */
function rangeLabel(range: DateRange | null): string | undefined {
  if (range === null) return undefined;
  const day = (iso: string): string =>
    `${String(Number(iso.slice(8, 10)))} ${MONTHS[Number(iso.slice(5, 7)) - 1] ?? ''}`;
  return `${day(range.start)} to ${day(range.end)}`;
}

export default function FilterBarBasic(): ReactElement {
  const [category, setCategory] = useState<Key | null>('groceries');
  const [accounts, setAccounts] = useState<readonly Key[]>([]);
  const [date, setDate] = useState<DateRange | null>(null);
  const [tags, setTags] = useState<readonly Key[]>(['shared']);
  return (
    <div style={{ width: '100%' }}>
      <FilterBar
        search={<SearchField aria-label="Search transactions" placeholder="Search transactions" />}
        onClearAll={() => {
          setCategory(null);
          setAccounts([]);
          setDate(null);
          setTags([]);
        }}
      >
        <FilterChip label="Category" selectionMode="single" value={category} onChange={setCategory}>
          <PickerOption id="groceries">Groceries</PickerOption>
          <PickerOption id="rent">Rent</PickerOption>
          <PickerOption id="transport">Transport</PickerOption>
        </FilterChip>
        <FilterChip label="Account" selectionMode="multiple" value={accounts} onChange={setAccounts}>
          <PickerOption id="checking">Checking</PickerOption>
          <PickerOption id="savings">Savings</PickerOption>
          <PickerOption id="card">Credit card</PickerOption>
        </FilterChip>
        <FilterChip
          label="Date"
          isSecondary
          valueLabel={rangeLabel(date)}
          onClear={() => {
            setDate(null);
          }}
        >
          <RangeCalendar aria-label="Date" value={date} onChange={setDate} today="2026-09-18" />
        </FilterChip>
        <FilterChip label="Tags" selectionMode="multiple" isSecondary value={tags} onChange={setTags}>
          <PickerOption id="shared">Shared</PickerOption>
          <PickerOption id="refund">Refund</PickerOption>
          <PickerOption id="business">Business</PickerOption>
        </FilterChip>
      </FilterBar>
    </div>
  );
}

On a phone

Under 768px of viewport, the row, More and Clear all become one Filters button beside the search, with the active count in a neutral badge. It opens a sheet titled "Filters" with every filter as a section, in the order you declared them.

The sheet is live: each change calls your onChange at once, so the URL and results follow. Done only closes it, as do swipe, Escape and the close button. Name Done with your result count through labels.done, such as "Show 128 results".

Chips in your own components

The bar reads chips that are its direct children, in fragments, or behind a condition such as {isAdmin && <FilterChip />}, so the count and More are in the server's HTML. A chip inside your own component cannot be read. It stays in the row, never behind More. Pass activeCount to give the count, counting every filter, since it replaces the bar's own. Give each chip a unique label: the bar tells chips apart by it.

Example

Changed
Follows the page theme and corners
'use client';

import { Checkbox, FilterBar, FilterChip, PickerOption, type Key } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

/** A chip in the app's own component: the bar cannot read its props, so it stays in the row. */
function AccountFilter({
  value,
  onChange,
}: {
  value: readonly Key[];
  onChange: (value: Key[]) => void;
}): ReactElement {
  return (
    <FilterChip label="Account" selectionMode="multiple" isSecondary value={value} onChange={onChange}>
      <PickerOption id="checking">Checking</PickerOption>
      <PickerOption id="savings">Savings</PickerOption>
    </FilterChip>
  );
}

export default function FilterBarWrapped(): ReactElement {
  const [category, setCategory] = useState<Key | null>('rent');
  const [accounts, setAccounts] = useState<readonly Key[]>(['checking']);
  const [client, setClient] = useState<Key | null>(null);
  const [isBusiness, setBusiness] = useState(false);
  // The bar counts the chips it can read and not the one in the app's own component, so the app counts them all.
  const activeCount =
    (category === null ? 0 : 1) + (isBusiness && client !== null ? 1 : 0) + (accounts.length > 0 ? 1 : 0);
  return (
    <div className="stack" style={{ width: '100%' }}>
      <FilterBar
        activeCount={activeCount}
        onClearAll={() => {
          setCategory(null);
          setClient(null);
          setAccounts([]);
        }}
      >
        {/* Chips in fragments are read, and so is a chip behind a condition. */}
        <>
          <FilterChip label="Category" selectionMode="single" value={category} onChange={setCategory}>
            <PickerOption id="groceries">Groceries</PickerOption>
            <PickerOption id="rent">Rent</PickerOption>
          </FilterChip>
          {isBusiness && (
            <FilterChip label="Client" selectionMode="single" value={client} onChange={setClient}>
              <PickerOption id="studio">Studio</PickerOption>
              <PickerOption id="bakery">Bakery</PickerOption>
            </FilterChip>
          )}
        </>
        <AccountFilter value={accounts} onChange={setAccounts} />
      </FilterBar>
      <Checkbox isSelected={isBusiness} onChange={setBusiness}>
        Business account
      </Checkbox>
    </div>
  );
}

The Transactions recipe

With the Next App Router, the URL is the only state. The server page awaits searchParams and reads every filter: category, a repeated account, min and max in minor units, and from and to. It computes the result count and each custom chip's valueLabel with its own formatters, then renders a client component with them:

// app/transactions/page.tsx, a server component
import { TransactionFilters } from './transaction-filters';

// An absent or malformed amount reads as none, as on the number field page.
const minorOf = (raw: string | string[] | undefined): number | null =>
  typeof raw !== 'string' || raw === '' || !Number.isSafeInteger(Number(raw)) ? null : Number(raw);

export default async function TransactionsPage({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const params = await searchParams;
  const list = (value: string | string[] | undefined) => (value === undefined ? [] : [value].flat());
  const filters = {
    category: typeof params.category === 'string' ? params.category : null,
    accounts: list(params.account),
    min: minorOf(params.min),
    max: minorOf(params.max),
    from: typeof params.from === 'string' ? params.from : null,
    to: typeof params.to === 'string' ? params.to : null,
  };
  const count = await countTransactions(filters);
  return (
    <TransactionFilters
      filters={filters}
      count={count}
      amountLabel={formatAmountRange(filters.min, filters.max)}
      dateLabel={formatDateRange(filters.from, filters.to)}
    />
  );
}

The client component writes each chip's change with the router, starting from the current parameters so the search and other filters stay.

  • A repeated parameter is deleted, then appended once per key.
  • A filter with no value deletes its parameter, and a bare ? is never written.
  • { scroll: false } keeps the scroll position.
  • Clear all deletes every filter parameter in one write.

The component shows a mirror of the filters in its own state, not the props. A write makes a round trip to the server before new props arrive. Until then, a chip would show its old value: a multiple list would undo a toggle under the pointer, and a cleared chip would stay active. The mirror takes each change at once. seen holds the query it last read or wrote, and the mirror reloads the server's filters only when the URL changes under it, by Back, Forward or a link. The amount and date words come from the server's formatters, so the mirror holds them too and blanks them when their chip clears. The sheet's Done names the server's count, "Show 1 result" or "Show 128 results":

'use client';

import { FilterBar, FilterChip, PickerOption, SearchField, type Key } from '@snapshot-ui/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { AmountFields, DateFields } from './filter-fields';

const FILTERS = ['category', 'account', 'min', 'max', 'from', 'to'];

interface Shown {
  category: Key | null;
  accounts: readonly Key[];
  amountLabel?: string;
  dateLabel?: string;
}

interface TransactionFiltersProps {
  filters: { category: string | null; accounts: string[] };
  count: number;
  /** The server's words for the two custom chips. */
  amountLabel?: string;
  dateLabel?: string;
}

export function TransactionFilters({ filters, count, amountLabel, dateLabel }: TransactionFiltersProps) {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const query = searchParams.toString();
  const fromServer: Shown = { ...filters, amountLabel, dateLabel };
  const [shown, setShown] = useState(fromServer);
  // The query the bar last read from the URL, or last wrote to it.
  const [seen, setSeen] = useState(query);
  if (query !== seen) {
    setSeen(query);
    setShown(fromServer);
  }
  const write = (next: Partial<Shown>, change: (params: URLSearchParams) => void) => {
    setShown((current) => ({ ...current, ...next }));
    const params = new URLSearchParams(searchParams);
    change(params);
    const written = params.toString();
    setSeen(written);
    router.replace(written ? `${pathname}?${written}` : pathname, { scroll: false });
  };
  return (
    <FilterBar
      search={<SearchField aria-label="Search transactions" />}
      labels={{ done: count === 1 ? 'Show 1 result' : `Show ${count} results` }}
      onClearAll={() =>
        write({ category: null, accounts: [], amountLabel: undefined, dateLabel: undefined }, (params) =>
          FILTERS.forEach((name) => params.delete(name)),
        )
      }
    >
      <FilterChip
        label="Category"
        selectionMode="single"
        value={shown.category}
        onChange={(category) =>
          write({ category }, (params) =>
            category === null ? params.delete('category') : params.set('category', String(category)),
          )
        }
      >
        <PickerOption id="groceries">Groceries</PickerOption>
        <PickerOption id="rent">Rent</PickerOption>
      </FilterChip>
      <FilterChip
        label="Account"
        selectionMode="multiple"
        value={shown.accounts}
        onChange={(accounts) =>
          write({ accounts }, (params) => {
            params.delete('account');
            for (const key of accounts) params.append('account', String(key));
          })
        }
      >
        <PickerOption id="checking">Checking</PickerOption>
        <PickerOption id="savings">Savings</PickerOption>
      </FilterChip>
      <FilterChip
        label="Amount"
        valueLabel={shown.amountLabel}
        onClear={() =>
          write({ amountLabel: undefined }, (params) => {
            params.delete('min');
            params.delete('max');
          })
        }
      >
        {({ close }) => <AmountFields onDone={close} />}
      </FilterChip>
      <FilterChip
        label="Date"
        isSecondary
        valueLabel={shown.dateLabel}
        onClear={() =>
          write({ dateLabel: undefined }, (params) => {
            params.delete('from');
            params.delete('to');
          })
        }
      >
        <DateFields />
      </FilterChip>
    </FilterBar>
  );
}

AmountFields and DateFields are your own: two money NumberFields that write min and max, and a RangeCalendar that writes from and to, the same way. The words for a new amount or range come with the server's next render.

  • Render the component inside <Suspense>, with the same bar in its first state as the fallback.
  • The search waits for a pause before it writes, as the search field page shows.

The example below writes category and a repeated tag, and reads them back after a reload.

Example

Follows the page theme and corners
'use client';

import { FilterBar, FilterChip, PickerOption, SearchField, type Key } from '@snapshot-ui/react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useState, type ReactElement } from 'react';

/** The filters this list reads from its URL: `category` once, `tag` once per tag. */
interface Filters {
  category: Key | null;
  tags: readonly Key[];
}

const NONE: Filters = { category: null, tags: [] };

const filtersOf = (params: URLSearchParams): Filters => ({
  category: params.get('category'),
  tags: params.getAll('tag'),
});

/** A fixed list of transactions, so the example can say how many the filters keep. */
const TRANSACTIONS: { category: string; tags: string[] }[] = [
  { category: 'groceries', tags: ['shared'] },
  { category: 'groceries', tags: [] },
  { category: 'rent', tags: ['shared'] },
  { category: 'transport', tags: ['business'] },
  { category: 'groceries', tags: ['refund'] },
  { category: 'rent', tags: [] },
];

/** How many transactions the filters keep. */
const countOf = ({ category, tags }: Filters): number =>
  TRANSACTIONS.filter(
    (each) =>
      (category === null || each.category === category) &&
      (tags.length === 0 || tags.some((tag) => each.tags.includes(String(tag)))),
  ).length;

/** "Show 1 result" or "Show 4 results": the app's own words, with no Intl. */
const showLabel = (count: number): string => `Show ${String(count)} ${count === 1 ? 'result' : 'results'}`;

function TransactionFilters({
  filters,
  onChange,
}: {
  filters: Filters;
  onChange?: (next: Filters) => void;
}): ReactElement {
  return (
    <FilterBar
      search={<SearchField aria-label="Search transactions" placeholder="Search transactions" />}
      labels={{ done: showLabel(countOf(filters)) }}
      onClearAll={
        onChange &&
        (() => {
          onChange(NONE);
        })
      }
    >
      <FilterChip
        label="Category"
        selectionMode="single"
        value={filters.category}
        onChange={(category) => onChange?.({ ...filters, category })}
      >
        <PickerOption id="groceries">Groceries</PickerOption>
        <PickerOption id="rent">Rent</PickerOption>
        <PickerOption id="transport">Transport</PickerOption>
      </FilterChip>
      <FilterChip
        label="Tags"
        selectionMode="multiple"
        isSecondary
        value={filters.tags}
        onChange={(tags) => onChange?.({ ...filters, tags })}
      >
        <PickerOption id="shared">Shared</PickerOption>
        <PickerOption id="refund">Refund</PickerOption>
        <PickerOption id="business">Business</PickerOption>
      </FilterChip>
    </FilterBar>
  );
}

function FiltersFromUrl(): ReactElement {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const key = searchParams.toString();
  const [filters, setFilters] = useState(() => filtersOf(new URLSearchParams(key)));
  // The query the bar last took from the URL, or last wrote to it.
  const [seen, setSeen] = useState(key);

  // The URL changed under the bar, by Back, Forward or a link: show its filters.
  if (key !== seen) {
    setSeen(key);
    setFilters(filtersOf(new URLSearchParams(key)));
  }

  return (
    <TransactionFilters
      filters={filters}
      onChange={(next) => {
        setFilters(next);
        // Start from the parameters already there, so the search and any other filter stay. A repeated
        // parameter is deleted, then appended once per key; Clear all is this one write too.
        const params = new URLSearchParams(searchParams);
        params.delete('category');
        if (next.category !== null) params.set('category', String(next.category));
        params.delete('tag');
        for (const tag of next.tags) params.append('tag', String(tag));
        const query = params.toString();
        setSeen(query);
        router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
      }}
    />
  );
}

export default function FilterBarUrl(): ReactElement {
  return (
    <div style={{ width: '100%' }}>
      {/* The page is static, so the server renders the fallback: the same bar, with nothing chosen. */}
      <Suspense fallback={<TransactionFilters filters={NONE} />}>
        <FiltersFromUrl />
      </Suspense>
    </div>
  );
}

Props

The bar's props, then its words. Each chip's props are on the filter chip page.

activeCount
number

The number of active filters, for chips wrapped in the app's own components.

Default: counted from children

children
ReactNode

FilterChips, directly or in fragments, in the order they show. Their labels are unique in the bar.

className
string

formatCount
((count: number) => string)

How the count is written, such as in Persian digits.

Default: String

labels
Partial<FilterBarLabels>

onClearAll
(() => void)

Clears every filter in one URL write. Shows Clear all while any filter is active.

ref
Ref<HTMLDivElement>

search
ReactNode

A SearchField, first in the row and kept beside the Filters button on a phone.

clear
string

The visible word of each section's clear button in More and the sheet; its name is the chip's.

Default: 'Clear'

clearAll
string

Default: 'Clear all'

done
string

The sheet's closing button; the app may pass "Show 128 results".

Default: 'Done'

filters
string

The row's name, the phone button's text and the sheet's title.

Default: 'Filters'

more
string

Default: 'More'

Accessibility

The row is a group named "Filters", from labels.filters. More is a button that opens a dialog it names. Each section in More and in the sheet is a region named by its h3 title. The sheet is a dialog titled "Filters". The Filters button is named by its text and the count, "Filters 2".

Focus never falls to the page when its element goes away:

  • After Clear all, focus goes to the first chip. In the sheet, it goes to Done.
  • After a secondary chip is cleared in the row, focus goes to More. The same happens when a secondary chip's list or panel closes with its value gone. The chip stays in the row while it is open.
  • After a choice in More leaves no chip behind it, More goes away, and the closing panel gives focus to that chip in the row.
  • When the sheet closes with the Filters button hidden, such as on a phone turned wide, focus goes to the first chip.
TabThrough the search, each chip and its clear button, More and Clear all; on a phone, the search and the Filters button.
EnterSpaceOpen a chip, the More panel or the sheet.
EscapeCloses the open panel or the sheet; focus goes back to what opened it.

Design rules

What keeps filter bars in the system.

Keep three or four chips primary and make the rest secondary.

Keep the URL as the only state of the filters.

Name Done with the result count.

Never move chips into More by measuring the row.

Never hold a draft with Apply in the sheet.

Never draw the Filters button in signal orange.

esc

↑ ↓ to move↵ to open