| Customer | Amount | Status | Method | Date |
|---|---|---|---|---|
| Bager Sara | 1.245,00 € | Captured | Visa | 8 Feb |
| Café Norden | 412,50 € | Captured | Mastercard | 8 Feb |
| Bistro Bjørn | 2.890,00 € | Refunded | Visa | 8 Feb |
| Brouwerij & co. | 732,75 € | Captured | Cartes Bancaires | 8 Feb |
| Polskie Smaki | 189,00 € | Captured | Maestro | 8 Feb |
5 of 5 rows
Overview
The Filters pattern composes three jobs into a single rail above the Table — text search, structured filters, and column visibility. Every input on the rail narrows the rows below; flipping a column toggles whether it's in the table at all. Applied filters surface back as removable chips so the user can unwind any single decision without re-opening the picker that set it.
The rail is the search; the table is the answer
Every change on the rail updates the Table immediately — no Apply button on desktop. The rail is the input, the Table is the response. The mobile drawer is the exception: a sticky Applycommits the whole draft at once so the user doesn't watch the table re-query on every tap.
Anatomy
A 48 px-tall row with four slot types. Search anchors the left; dropdowns and date steppers fill the middle; the columns icon sits on the right.
- Search ·
<Search size="md">fluid input. Carries the magnifier glyph and the inline Clear. - DatePicker ·
variant="stepper"— chevron-flanked label, calendar drops on click. - Select · enum filter (Status). Trigger flips to the foreground fill when applied.
- Combobox · filterable list (Method). Reach for it whenever the option set is long enough to scan-search.
Search
280 px wide. Inter Tight Medium 16 / 24 placeholder in
text.accent.neutral. Updates the Table on every keystroke; debounced upstream.Date stepper
Two 40 × 48 chevron buttons flanking a 140 px-min label. Anchored to a single date or a range; the format follows
en-GB(8 Feb 2026).Dropdown filter
48 px-tall pill trigger with a label and a chevron-down glyph. Inverts to
background.selected(pure black) and the label flips to white when the filter is applied.Columns toggle
48 × 48 ghost icon button. Opens the columns menu — a 260 px popover with per-column visibility + drag-to- reorder.
Desktop
The rail spans the Table width. The search field anchors left; the columns toggle sits flush right via margin-left: auto. The middle slots fan out left-to-right in priority order — the filter the user reaches for most often sits closest to the search.
Mobile
Below 600 px the rail collapses. Search fills the row; a single filter glyph carries a green-tone applied-count badge; a more_vert overflow houses the columns menu. Tapping the filter glyph opens a bottom-sheet drawer with the full filter list.
Column visibility
Hide a column when the user doesn't need it on this surface today — never as a permanent escape hatch for over-broad tables. The columns menu lists every column; toggling a column off line-throughs its label. Drag handles let the user reorder before they hide.
Columns menu
Composed from Menu + MenuItemCheckbox — one checkbox per column. The menu stays open between toggles (closeOnSelect=false is the default for checkbox items) so the user can hide several columns in one pass.
Applied filters
Every applied filter surfaces as a removable Chip below the rail. The user removes a single filter by clicking its X; a Clear all link sits flush right of the chip group when more than one is applied.
Behavior
- Desktop applies live. Each change to the rail updates the Table on the next tick — no Apply button. The user reads the table's row count line as the canonical confirmation.
- Mobile applies on commit. The drawer holds a draft — every tap inside the drawer modifies the draft, never the live state. Apply commits the draft; Clear empties it. Closing the drawer without Apply discards the draft.
- Mirror to the URL. Active filters belong in the query string (
?status=Captured&method=Visa) so the page is shareable. Pasting the URL re-applies the filters on load. - Search is debounced. 200 ms after the last keystroke is enough — under that the user feels keystrokes lag, over that the table feels unresponsive.
- Hidden columns persist per user. The columns choice is a preference, not a query — store it against the user, not the URL. The URL captures what the user is looking at; the preference captures how they look at it.
- Empty results stay informative. When the rail filters every row out, render a Table empty state that explains whichfilter is excluding — don't leave the user staring at “No results” and a search field they can't see.
Accessibility
- Roles: the rail is a plain
<div>wrapping native controls — a<input type="search">for search, native<button>triggers for the dropdowns. Each dropdown menu usesrole="menu"+role="menuitem". - Drawer: the bottom sheet is a
role="dialog"witharia-modal="true". Focus traps inside;Escapedismisses; scrolling the page underneath is locked. The drag-handle indicator is decorative —aria-hidden. - Live region:the Table's row count line is wrapped in
aria-live="polite"so changes from the rail are announced — “5 of 124 rows” — without stealing focus. - Keyboard:
/focuses search from anywhere on the route;Tabwalks the rail in DOM order; arrow keys navigate inside an open dropdown.Backspaceon a focused applied-filter chip removes it. - Touch targets:every control is 48 px tall on desktop; the mobile filter glyph and the more_vert overflow are 48 × 48 px. The applied-count badge on mobile is decorative — the button's aria-label includes the applied count so it announces as “Open filters, 3 applied”.
Code
The pattern composes three primitives — a FilterBar for the rail, a FilterDrawer for the mobile bottom sheet, and a ColumnsMenu popover. State is owned by the page; the components emit changes.
tsx
import {
FilterBar,
FilterDrawer,
MobileFilterBar,
ColumnsMenu,
Table,
} from "@flatpay-dk/ui";
import { useMediaQuery } from "@flatpay-dk/ui/hooks";
const FILTERS = [
{ id: "status", label: "Status", options: ["Captured", "Refunded", "Disputed"] },
{ id: "method", label: "Method", options: ["Visa", "Mastercard"] },
];
const ALL_COLUMNS = [
{ id: "id", label: "Reference" },
{ id: "customer", label: "Customer" },
{ id: "amount", label: "Amount" },
{ id: "status", label: "Status" },
{ id: "method", label: "Method" },
{ id: "date", label: "Date" },
];
const isMobile = useMediaQuery("(max-width: 600px)");
const [search, setSearch] = useState("");
const [values, setValues] = useState<Record<string, string | undefined>>({});
const [hidden, setHidden] = useState(new Set<string>());
const [drawerOpen, setDrawerOpen] = useState(false);
const [columnsOpen, setColumnsOpen] = useState(false);
const appliedCount = Object.values(values).filter(Boolean).length;
return (
<>
{isMobile ? (
<>
<MobileFilterBar
search={search}
onSearchChange={setSearch}
appliedCount={appliedCount}
onOpenFilters={() => setDrawerOpen(true)}
/>
<FilterDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
filters={FILTERS}
values={values}
onApply={setValues}
/>
</>
) : (
<FilterBar
search={search}
onSearchChange={setSearch}
filters={FILTERS}
values={values}
onValueChange={(id, v) => setValues((prev) => ({ ...prev, [id]: v }))}
onColumns={() => setColumnsOpen((o) => !o)}
/>
)}
<Table
rows={filteredRows}
columns={ALL_COLUMNS.filter((c) => !hidden.has(c.id))}
/>
</>
);Best practices
Do
Live-apply on desktop. The user types, the table responds. The Apply button has nothing to do here.
Don't
Don't add an Apply button on desktop — the user already knows the rail works because the table just changed.
Do
Mirror filters to the URL so the page is shareable and the back button restores state.
Don't
Don't keep filters in component state only. The user pastes the URL and gets a different page than the one they were on.
No transactions match
Try removing Status: Disputed or expanding the date range.
Do
Empty state names which filter is excluding so the user knows what to drop.
No results
Don't
Don't render a generic 'No results' line — the user has nowhere to go from here.
Props
FilterBar
| Prop | Type | Default | Description |
|---|---|---|---|
| search | string | — | Current search query. Pair with onSearchChange — the field updates live on every keystroke. |
| onSearchChange | (value: string) => void | — | Fires on every keystroke. Debounce upstream (200 ms) before re-querying. |
| date / onDateChange | Date / (next: Date) => void | — | Optional date stepper slot. Pass both to render the chevron-flanked date label. |
| filters | FilterDef[] | — | Array of { id, label, options } for the dropdown slots. Renders one dropdown per definition. |
| values | Record<string, string | undefined> | — | Current applied values keyed by filter id. Pass undefined to clear a filter. |
| onValueChange | (id: string, value: string | undefined) => void | — | Fires when a dropdown applies or clears a value. |
| onColumns | () => void | — | Fires when the columns toggle button is activated. Owner opens a ColumnsMenu in response. |
MobileFilterBar
| Prop | Type | Default | Description |
|---|---|---|---|
| search / onSearchChange | string / (v: string) => void | — | Same contract as the desktop FilterBar. |
| appliedCount | number | 0 | When greater than zero, a green-tone badge sits on the filter glyph showing the count. |
| onOpenFilters | () => void | — | Fires when the user taps the filter glyph. Owner opens a FilterDrawer in response. |
| onOverflow | () => void | — | Fires when the user taps the more_vert overflow. Owner opens a Menu (columns, export, etc.) in response. |
FilterDrawer
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Whether the bottom sheet is mounted and visible. |
| onClose | () => void | — | Fires on dim-tap, Escape, the close button, or after Apply commits. |
| filters | FilterDef[] | — | Same shape as FilterBar.filters. Each filter renders as a row that drills into a choice list. |
| values | Record<string, string | undefined> | — | Initial state. The drawer copies these into a draft on open; Apply commits the draft. |
| onApply | (next: Record<string, string | undefined>) => void | — | Fires when the sticky Apply button is tapped. The owner commits to the live state and closes the drawer. |
ColumnsMenu
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | ColumnDef[] | — | Every column the Table can render, in canonical order. |
| hidden | Set<string> | — | Set of column ids the user has hidden. Hidden columns are line-through in the menu. |
| onToggle | (id: string) => void | — | Fires when the visibility toggle is activated for a column. |
| inline | boolean | true | When true, renders as a popover anchored to its trigger. When false, renders as a panel (used inside the mobile drawer). |