Patterns

Filters

The horizontal rail that sits above a Table — search, scoped dropdowns, applied chips, and a columns toggle. Two layouts: a desktop rail that fans out across the row, and a compact mobile rail that pushes the filters into a bottom-sheet drawer. The rail is the search, the drawer is the storage.

Documentedby Derek Fidler
CustomerAmountStatusMethodDate
Bager Sara1.245,00 €CapturedVisa8 Feb
Café Norden412,50 €CapturedMastercard8 Feb
Bistro Bjørn2.890,00 €RefundedVisa8 Feb
Brouwerij & co.732,75 €CapturedCartes Bancaires8 Feb
Polskie Smaki189,00 €CapturedMaestro8 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.
  1. Search

    280 px wide. Inter Tight Medium 16 / 24 placeholder in text.accent.neutral. Updates the Table on every keystroke; debounced upstream.

  2. 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).

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

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

Responsive layout
360 px
Below @md (≤ 448 px): Date, Status, and Method all fold into the More Button. Search keeps the row. The applied count chip on More tracks anything hidden.

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 uses role="menu" + role="menuitem".
  • Drawer: the bottom sheet is a role="dialog" with aria-modal="true". Focus traps inside; Escape dismisses; 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; Tab walks the rail in DOM order; arrow keys navigate inside an open dropdown. Backspace on 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.

/transactions?status=Captured&method=Visa&date=2026-02-08

Do

Mirror filters to the URL so the page is shareable and the back button restores state.

/transactions — filters lost on reload

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

PropTypeDefaultDescription
searchstringCurrent search query. Pair with onSearchChange — the field updates live on every keystroke.
onSearchChange(value: string) => voidFires on every keystroke. Debounce upstream (200 ms) before re-querying.
date / onDateChangeDate / (next: Date) => voidOptional date stepper slot. Pass both to render the chevron-flanked date label.
filtersFilterDef[]Array of { id, label, options } for the dropdown slots. Renders one dropdown per definition.
valuesRecord<string, string | undefined>Current applied values keyed by filter id. Pass undefined to clear a filter.
onValueChange(id: string, value: string | undefined) => voidFires when a dropdown applies or clears a value.
onColumns() => voidFires when the columns toggle button is activated. Owner opens a ColumnsMenu in response.

MobileFilterBar

PropTypeDefaultDescription
search / onSearchChangestring / (v: string) => voidSame contract as the desktop FilterBar.
appliedCountnumber0When greater than zero, a green-tone badge sits on the filter glyph showing the count.
onOpenFilters() => voidFires when the user taps the filter glyph. Owner opens a FilterDrawer in response.
onOverflow() => voidFires when the user taps the more_vert overflow. Owner opens a Menu (columns, export, etc.) in response.

FilterDrawer

PropTypeDefaultDescription
openbooleanWhether the bottom sheet is mounted and visible.
onClose() => voidFires on dim-tap, Escape, the close button, or after Apply commits.
filtersFilterDef[]Same shape as FilterBar.filters. Each filter renders as a row that drills into a choice list.
valuesRecord<string, string | undefined>Initial state. The drawer copies these into a draft on open; Apply commits the draft.
onApply(next: Record<string, string | undefined>) => voidFires when the sticky Apply button is tapped. The owner commits to the live state and closes the drawer.

ColumnsMenu

PropTypeDefaultDescription
columnsColumnDef[]Every column the Table can render, in canonical order.
hiddenSet<string>Set of column ids the user has hidden. Hidden columns are line-through in the menu.
onToggle(id: string) => voidFires when the visibility toggle is activated for a column.
inlinebooleantrueWhen true, renders as a popover anchored to its trigger. When false, renders as a panel (used inside the mobile drawer).