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
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}
/>
}
/>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.
Related
- /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.