Patterns
Filtering
When filters apply instantly or in a batch, how results are announced and why filters live in the URL.
Filters reduce a long list to the set a user is looking for. This pattern fixes when filters apply, how the result is announced and how an empty result is handled.
When to use
Section titled “When to use”- On table and list screens where the number of records is too large to scan at a glance.
- When users need to return to the same set or share it; filters are kept in the URL.
- When one search box is enough, use only the search field instead of
FilterBar.
Live example
Section titled “Live example”Type in the search box, then pick a status or a category. The result count appears under the bar. When no row matches, the table shows its empty state with a “Clear filters” button.
| Status | Category | ||
|---|---|---|---|
| Item 1 | Category A | 50 | |
| Item 2 | Category B | 100 | |
| Item 3 | Category C | 150 | |
| Item 4 | Category A | 200 | |
| Item 5 | Category B | 250 | |
| Item 6 | Category C | 300 | |
| Item 7 | Category A | 350 | |
| Item 8 | Category B | 400 | |
| Item 9 | Category C | 450 | |
| Item 10 | Category A | 500 | |
| Item 11 | Category B | 550 | |
| Item 12 | Category C | 600 |
(empty)import { createDataTableColumnHelper, DataTable, FilterBar, filtersToSearchParams, Stack, StatusBadge, Text, type FilterField } from '@bigbang/ui';import { useEffect, useMemo, useState } from 'react';import { type Lang, LangProvider } from '../lang';
const copy = { tr: { caption: 'Öğe listesi', search: 'Ara', searchPlaceholder: 'Ada göre ara', status: 'Durum', category: 'Kategori', statuses: { active: 'Etkin', pending: 'Beklemede', draft: 'Taslak', completed: 'Tamamlandı' }, categories: { a: 'Kategori A', b: 'Kategori B', c: 'Kategori C' }, name: 'Ad', count: 'Adet', item: 'Öğe', url: 'Adres sorgusu', noFilters: '(boş)', }, en: { caption: 'Item list', search: 'Search', searchPlaceholder: 'Search by name', status: 'Status', category: 'Category', statuses: { active: 'Active', pending: 'Pending', draft: 'Draft', completed: 'Completed' }, categories: { a: 'Category A', b: 'Category B', c: 'Category C' }, name: 'Name', count: 'Count', item: 'Item', url: 'URL query', noFilters: '(empty)', },};
type Filters = { query: string; status: string | null; category: string | null };type Row = { id: string; name: string; status: 'active' | 'pending' | 'draft' | 'completed'; category: 'a' | 'b' | 'c'; count: number };
const empty: Filters = { query: '', status: null, category: null };const statusOrder = ['active', 'pending', 'draft', 'completed'] as const;const categoryOrder = ['a', 'b', 'c'] as const;const DEBOUNCE = 300;
const helper = createDataTableColumnHelper<Row>();
/** * Filters above a table. In `instant` mode every change filters at once; the search text waits 300 ms after the last key. * In `batch` mode the draft is applied with the Apply button. The URL query line shows what an app would keep in the address. */export function FilteringDemo({ lang = 'tr', apply = 'instant' }: { lang?: Lang; apply?: 'instant' | 'batch' }) { const t = copy[lang]; const [filters, setFilters] = useState<Filters>(empty); const [debounced, setDebounced] = useState('');
// Typing is debounced in instant mode only; batch mode filters when Apply is pressed. useEffect(() => { if (apply === 'batch') return; const timer = setTimeout(() => setDebounced(filters.query), DEBOUNCE); return () => clearTimeout(timer); }, [filters.query, apply]);
const fields = useMemo<ReadonlyArray<FilterField<Filters>>>( () => [ { key: 'query', label: t.search, kind: 'search', placeholder: t.searchPlaceholder }, { key: 'status', label: t.status, kind: 'select', options: statusOrder.map((value) => ({ value, label: t.statuses[value] })) }, { key: 'category', label: t.category, kind: 'select', options: categoryOrder.map((value) => ({ value, label: t.categories[value] })) }, ], [t], );
const rows = useMemo<Row[]>( () => Array.from({ length: 12 }, (_, index) => ({ id: `row-${index + 1}`, name: `${t.item} ${index + 1}`, status: statusOrder[index % statusOrder.length] ?? 'draft', category: categoryOrder[index % categoryOrder.length] ?? 'a', count: (index + 1) * 50, })), [t], );
const shown = useMemo(() => { const query = (apply === 'batch' ? filters.query : debounced).trim().toLocaleLowerCase(lang); return rows.filter( (row) => row.name.toLocaleLowerCase(lang).includes(query) && (filters.status === null || row.status === filters.status) && (filters.category === null || row.category === filters.category), ); }, [rows, apply, filters.query, debounced, filters.status, filters.category, lang]);
const columns = useMemo( () => [ helper.accessor('name', { header: t.name, meta: { rowHeader: true } }), helper.accessor('status', { header: t.status, enableSorting: false, cell: (context) => <StatusBadge status={context.getValue()} /> }), helper.accessor('category', { header: t.category, enableSorting: false, cell: (context) => t.categories[context.getValue()] }), helper.accessor('count', { header: t.count, meta: { numeric: true } }), ], [t], );
const clear = () => { setFilters(empty); setDebounced(''); }; const queryString = filtersToSearchParams(filters, fields).toString();
return ( <LangProvider lang={lang}> <Stack gap="lg"> <FilterBar<Filters> fields={fields} value={filters} onValueChange={setFilters} apply={apply} resultCount={shown.length} defaultValue={empty} /> <DataTable<Row> columns={columns} data={shown} getRowId={(row) => row.id} caption={t.caption} captionHidden status={shown.length === 0 ? 'filtered-empty' : 'ready'} onClearFilters={clear} /> <Text size="sm" tone="subtle"> {t.url}: <code>{queryString ? `?${queryString}` : t.noFilters}</code> </Text> </Stack> </LangProvider> );}Use batch mode for data where every change starts a slow server query. Changes wait in a draft and apply together with “Apply”.
| Rule | Value |
|---|---|
Instant mode (apply="instant") |
Local or fast data. Every change applies at once. |
Batch mode (apply="batch") |
Every change starts a slow server query. “Apply” is enabled only when the draft changed. |
| Search delay | Filters 300 ms after the last keystroke. |
| Applied filters | Shown as removable chips under the bar. |
| Result count | Shown on the chips row as “12 results”. |
| URL | Filters are written with filtersToSearchParams and read with searchParamsToFilters. |
| Empty result | status="filtered-empty" and onClearFilters; the message comes with a “Clear filters” button. |
| Enter motion | Rows do not animate in when filters change. |
Behaviour
Section titled “Behaviour”- The user picks a filter or types in the search box. In instant mode the table updates at once; for search, 300 ms after the last key.
- In batch mode changes stay in a draft. The user presses “Apply”, or presses Enter in a text field.
- Each applied filter becomes a chip. Removing a chip removes only that filter; “Clear filters” removes all of them and moves focus to the first control.
- The result count updates. With no results, the table shows the empty message and the clear button.
- The app writes the filters to the URL. When the page opens, valid values in the URL become filters; unknown values are ignored.
The product decides whether search text belongs in the URL. Do not write searches that may hold personal data to the URL.
Accessibility
Section titled “Accessibility”- The filter area has
role="search"and the name “Filters”. - The result count is read once and politely (
aria-live="polite") after it stays unchanged for 500 ms, so typing does not flood screen readers. - While data loads, the filter area is
aria-busyand the count is replaced by a placeholder. - The remove button of each chip names the filter: “Remove Status filter”.
- After “Clear filters”, focus moves to the first control in place of the button that disappeared.
- Filter state is conveyed in text, not by colour alone (WCAG 1.4.1, 4.1.3).
Content
Section titled “Content”- Field names are short noun phrases: “Status”, “Category”.
- The result count gives the number and the unit: “12 results”.
- An empty result says what is the case: “No records match the filters”. Do not blame the user.
- Button text starts with a verb: “Apply”, “Reset”, “Clear filters”.
Motion
Section titled “Motion”| Item | Motion | Preset |
|---|---|---|
| Rows after a filter | No enter motion; rows change in place | none |
| Result count | 110 ms cross-fade | motion.reveal |
| More filters panel | 150 ms open, 4 px shift | motion.collapsible |
| Loading placeholder | 1500 ms shimmer, 3 times, then still | motion.skeleton |
With reduced motion, shifts are removed and only fades of 110 ms or less stay.
Components used
Section titled “Components used”FilterBar, DataTable, StatusBadge, Tag, Select, Button. Related patterns: Empty states, Bulk actions, Data export.