Skip to content
BigBang

Popular

    ↑↓Navigate↵OpenEscClose
    TR

    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.

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

    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.

    Live example, StorybookOpen in Storybook
    Item list
    StatusCategory
    Item 1ActiveCategory A50
    Item 2PendingCategory B100
    Item 3DraftCategory C150
    Item 4CompletedCategory A200
    Item 5ActiveCategory B250
    Item 6PendingCategory C300
    Item 7DraftCategory A350
    Item 8CompletedCategory B400
    Item 9ActiveCategory C450
    Item 10PendingCategory A500
    Item 11DraftCategory B550
    Item 12CompletedCategory C600
    URL query: (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”.

    Live example, StorybookOpen in Storybook
    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.
    DoAn empty result offers a way out as an action.
    No results.
    Don'tA message without an action leaves users stuck.
    1. 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.
    2. In batch mode changes stay in a draft. The user presses “Apply”, or presses Enter in a text field.
    3. 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.
    4. The result count updates. With no results, the table shows the empty message and the clear button.
    5. 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.

    • 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-busy and 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).
    • 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”.
    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.

    FilterBar, DataTable, StatusBadge, Tag, Select, Button. Related patterns: Empty states, Bulk actions, Data export.