Patterns

Filtering

The go-to composition for narrowing a collection — a friendly row of filter buttons above the data, chips for everything you've applied, and a one-tap clear-all. Your applied filters never hide — one glance tells you exactly what's narrowing the view. Selection UI comes from the standard control set; no novel pickers.

Anatomy

The filter region sits directly above the collection it narrows. Four parts, top to bottom:

Filter triggers
One labeled pill per filter dimension. Each opens a solid, flat popover holding the selection control — a checkbox list for discrete values, a range calendar with presets for dates. An applied trigger fills with a soft gold tint, so the dimension reads as active before you scan the chips. (The glassy popover treatment belongs to the proposed system — here, surfaces are flat and opaque.)
Applied-filter chips
One chip per applied dimension, reading "Dimension: value" (or a count when several values are selected), each with its own × dismiss. Never collapsed, never hidden behind a disclosure.
Clear all
A single affordance that resets every dimension in one action. Present only while at least one filter is applied.
Filtered collection
The table, list, or grid being narrowed. When the criteria match nothing, it hands off to the empty-state pattern — never a blank region.

Demo

A FilterBar narrowing a list of marketing collateral — try it! Apply a status, a channel, or a reviewed-date range and watch the chips stack up. Narrow it all the way to nothing to see the empty-result handoff.

8 of 8 pieces shown

  • Spring open-house flyerPrintApproved2026-05-28
  • Rate-drop email blastEmailIn review2026-06-02
  • HELOC explainer videoSocialApproved2026-05-19
  • First-time buyer landing pageWebIn review2026-06-05
  • Co-marketing postcardPrintRetired2026-03-30
  • VA loan social carouselSocialApproved2026-05-26
  • Refi calculator pageWebApproved2026-04-22
  • Branch grand-opening inviteEmailRetired2026-02-14

Principles

  • Applied filters are always visible. Never folded into a collapsed "Filters (3)" disclosure. A narrowed view whose criteria are hidden is a view the user cannot trust — every chip stays in the region, in reading order.
  • Every filter is individually dismissible. The × on each chip removes one dimension; Clear all resets the whole set in one action. Both, always — removing three filters one × at a time is toil, and clearing everything to remove one is data loss.
  • Standard control set only. Selection UI is a checkbox list, a Selector / Combobox, or the range calendar — picked by cardinality. A novel picker per filter is a cost the user pays again on every collection.
  • Filter state is URL-reflectable. Applied filters serialize to URL search params, so a narrowed view is shareable and survives reload.

Choosing the control

The popover body is picked by the dimension's cardinality — never invented per filter.

Low cardinality
A checkbox list in the popover — every option visible, multi-select by default. The FilterBar's select type renders this.
High cardinality
A Selector / Combobox with type-ahead — search the options instead of scanning them. Reach for this when the checkbox list would scroll.
Dates
The range calendar with quick presets (Today · Last 7 days · Last 30 days · This quarter). Presets are how metadata filtering actually happens — "created in the last 30 days" is one press, not two calendar picks.

Chips

Applied filters render as chips and inherit the Chip component's treatment — the slot length cap and popover overflow (ADR-0015) apply to user-named values here exactly as they do everywhere else a chip appears. Today FilterBar renders its own chip styling internally; it adopts the standalone Chip component (Beta) as that lands — same anatomy, one implementation.

Empty results

Filtering down to zero isn't an error — it's a state the user built, so the way back belongs right inside the empty state: say what happened ("No results match these filters") and offer Clear filters as the friendly way out. This is the empty-states pattern's filtered-to-empty case — same composition, with the recovery action wired to the filter state. A zero-result region that just goes blank reads as a broken page.

URL reflection

The recipe calls for applied filters serializing to URL search params, so a narrowed view can be shared, bookmarked, and reloaded. The demo above holds its state in memory; product surfaces bind the same columnFilters state to the router's search params instead — same state shape, different store.

Accessibility

  • Triggers are real buttons. Each filter trigger is a Button with aria-haspopup="dialog" and aria-expanded; the popover is a labeled dialog, Escape closes it, and focus returns to the trigger.
  • Chip dismissal is labeled. Each × carries "Remove filter: <dimension>" — never an unlabeled icon button.
  • Result changes are announced. The result count is a polite live region (role="status"), so applying a filter announces the narrowed count without stealing focus.
  • Reduced motion. The popover rise and the empty-state settle both collapse to plain appearance under prefers-reduced-motion.

API

filters: FilterDef[]
The declarative filter dimensions — { id, label, type: "select", options } or { id, label, type: "dateRange" }. The id names the column / field it narrows.
columnFilters / onColumnFiltersChange
Controlled TanStack ColumnFiltersState. Pass the same pair to DataTable and the bar drives the table directly; against any other collection, apply the state yourself (the demo above does).
labels
All strings — clear-all, the per-chip dismiss, the date presets. Wire them through your messages; nothing is hardcoded for i18n surfaces.
standardMetadataFilters(opts)
The recurring metadata set (status, created/updated by, created/updated date) as a FilterDef[] to spread and extend with collection-specific dimensions.
inSelectionFilter / dateRangeFilter
The matching filterFn implementations to attach to DataTable columns so the table narrows by the same semantics the bar declares.

Usage

FilterBar in a DataTable toolbartsx
import {
  DataTable, FilterBar,
  inSelectionFilter, dateRangeFilter, standardMetadataFilters,
  type ColumnFiltersState,
} from '@halo-compliance/ui'

const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const filters = standardMetadataFilters({ statusOptions, users })

<DataTable
  columns={columns} // columns carry the matching filterFn
  data={reports}
  columnFilters={columnFilters}
  onColumnFiltersChange={setColumnFilters}
  toolbar={
    <FilterBar
      filters={filters}
      columnFilters={columnFilters}
      onColumnFiltersChange={setColumnFilters}
    />
  }
/>
Custom dimensions against any collection, with the empty-result handofftsx
import { FilterBar, type FilterDef } from '@halo-compliance/ui'

const filters: FilterDef[] = [
  { id: 'channel', label: 'Channel', type: 'select', options: channelOptions },
  { id: 'reviewedAt', label: 'Reviewed', type: 'dateRange' },
]

const visible = applyFilters(pieces, columnFilters) // your own predicate

<FilterBar
  filters={filters}
  columnFilters={columnFilters}
  onColumnFiltersChange={setColumnFilters}
/>

{visible.length === 0 ? (
  // empty-states pattern, filtered-to-empty case:
  // name what happened + a "Clear filters" recovery action
  <FilteredToEmpty onClear={() => setColumnFilters([])} />
) : (
  <PieceList pieces={visible} />
)}

Anti-patterns

  • Don't hide applied filters. A collapsed "Filters (3)" pill makes the narrowed view unreadable — the user sees fourteen rows and has no idea why it isn't forty. Chips stay out, always.
  • Don't invent a picker per filter. The control set is fixed: checkbox list, Selector / Combobox, range calendar. A bespoke severity dial or a tag cloud is novelty the user has to relearn on every collection.
  • Don't use filtering for free-text query. That is search — a different pattern with different mechanics. Filtering narrows by structured criteria; the two compose, they don't merge.
  • Don't leave an empty result blank. Filtered-to-zero without a named empty state and a Clear filters action reads as a broken page, not a narrow filter.
  • /data-table — FilterBar's native host; the toolbar slot and the matching filterFns.
  • /collection — the view family whose toolbar this pattern fills.
  • /error-states — the sibling fallback pattern; an error is the system's fault, an empty result is the filter's.
  • /selector — the high-cardinality selection control inside filter popovers.