Skip to content

FilterBar

An agent-native filter system — a headless `useFilters` engine plus a bar of editable `field · operator · value` pills whose operators derive from field type.

Stablev0.4.2added in v0.2.0@garn/ui/filters
On this page

Default

A self-contained FilterBar over a records array — `+ Filter` → field → operator → value, keyboard-operable chips.

Prefilled

A controlled model seeded with rules — the editable-pill state, per-segment editing.

Live Results

The engine's `predicate` wired to a live result list (`engine.matches`).

Full Matrix

Every field type (text/number/date/select/multi/user) with its operators + value editor, incl. relative dates and deadline mode.

Nested Groups

Nested AND/OR groups — the group-outline querybuilder tree with one conjunction per group.

Ask

The natural-language Ask bar parsing a phrase into editable chips (custom layout via compound children).

Saved Views

Saved filter views from a localStorage store (createLocalViewStore).

Url Round Trip

serialize/deserialize round-trip through a URL query string, with validate-and-repair on restore.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add filters
First time? Set up garn in your project
Registry
filters
Deps
lucide-react
Registry deps
utilsdate-iouse-controllable-stateuse-announceuse-debounced-valuetagpopovercommandinputnumber-fielddate-pickerswitchspinner

Import

import { FilterBar, FilterBar.Chips, FilterBar.Add, FilterBar.ResultCount, FilterBar.Clear, FilterBar.SavedViews, FilterBar.Ask, FilterBar.Empty, FilterBar.Loading, FilterBar.Error } from "@garn/ui/filters";

Anatomy

The parts this component is built from — compose them to assemble it.

FilterBarRequired[data-slot="filter-bar"]

The root provider + default layout. Pass fields (+ optional records) and it creates its own engine, or adopt an externally-owned one via engine; apiRef exposes the FilterApi. With no compound children it renders the canonical bar; pass children to arrange the parts yourself.

FilterBar.Chips[data-slot="filter-chips"]

The pill row (flat roving grid, or the nested querybuilder tree when a group is present).

FilterBar.Add[data-slot="filter-add"]

The + Filter field-menu trigger.

FilterBar.ResultCount[data-slot="filter-result-count"]

The role="status" live result count (client mode).

FilterBar.Clear[data-slot="filter-clear"]

The reset-all button.

FilterBar.SavedViews[data-slot="filter-saved-views"]

Named filter presets from an injectable store (createLocalViewStore default).

FilterBar.Ask[data-slot="filter-ask"]

The natural-language bar → editable chips.

FilterBar.Empty[data-slot="filter-empty"]

The empty state.

FilterBar.Loading[data-slot="filter-loading"]

The loading state (keep-previous).

FilterBar.Error[data-slot="filter-error"]

The error state (role="alert") with an optional onRetry.

Props

The component's public props and their types.

The root has no props of its own — it's a thin wrapper over its native element. Its parts carry the API:

FilterBar

apiRef
Description
Imperative handle (agent-native).
Type
React.Ref<FilterApi>
defaultFilters
Description
The initial filter model (uncontrolled).
Type
FilterGroup
engine
Description
Adopt an externally-owned engine (data-table / CollectionView pass theirs).
Type
UseFiltersReturn<T>
fields
Description
Field schema — required unless an `engine` is supplied.
Type
FilterField<T>[]
filters
Description
The filter model. To let the bar manage its own model, use `defaultFilters` instead.
Type
FilterGroup
onFiltersChange
Description
Fires with the new model on every edit — add, change, remove, clear.
Type
(model: FilterModel) => void
records
Description
Records for the client pipeline (drives the result count).
Type
readonly T[]
size
Description
Control density of the bar and its chips.
Type
"md" | "sm"
Default
"md"

Plus 279 inherited native HTML attributes.

FilterBar.Ask

placeholder
Description
Input placeholder.
Type
string
Default
"Ask… e.g. status is done and amount > 1000"

Plus 307 inherited native HTML attributes.

FilterBar.Error

onRetry
Description
When set, renders a Retry button that calls it (e.g. re-run a failed option fetch).
Type
() => void

Plus 280 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="filter-bar"][data-slot="filter-chips"][data-slot="filter-chip"][data-slot="filter-chip-operator"][data-slot="filter-chip-value"][data-slot="filter-chip-remove"][data-slot="filter-value-editor"][data-slot="filter-operator-menu"][data-slot="filter-conjunction"][data-slot="filter-group"][data-slot="filter-add"][data-slot="filter-result-count"][data-slot="filter-clear"][data-slot="filter-saved-views"][data-slot="filter-ask"][data-slot="filter-empty"][data-slot="filter-loading"][data-slot="filter-error"]

States

SelectorState
[data-state=complete]complete
[data-state=pending]pending
[data-slot=filter-group]nested
[data-slot=filter-loading]loading
[data-slot=filter-error]error
[data-slot=filter-empty]empty
PropertyTokenTier
gap--garn-gap-inlinesemantic · air

Private instance vars (never externalize): --t-soft-fg--t-line

When to use

Letting users author a predicate over a collection: a data-table / list / board filter bar, a faceted search, a saved-segment view, or an agent/MCP surface that returns editable filter chips. Use <FilterBar fields={…} records={…}/> for a self-contained bar, or drive useFilters yourself and pass the engine to share one model with a table/CollectionView.

Reach for something else when

  • Plain membership toggles with no operators over a couple of columns (a simpler faceted-checkbox popover / the faceted-filter recipe is lighter — Filters is the count-annotated value editor for exactly that, but the full bar is overkill for one column). Free-text search of a list (use an Input type=search). Picking one/several values as a form field, not as a query (use Select / MultiSelect / BadgeSelect). Running an action or navigating (use Command). Sorting or reordering rows (use Sortable / the table's sort).

Overview

Filters is garn's agent-native filter system — a headless useFilters<T> engine plus a FilterBar compound, the same shape as Sortable (an engine + a composed UI that re-skins nothing). The reliable unit is an editable pill: field · operator · value, where the operator set is a pure function of the field's type, so an impossible query — a comparison on an enum, contains on a number — simply can't be authored. Below the pills, useFilters owns a serializable, nestable FilterModel ({ id, conjunction, rules }, recursive), compiles it to a memoized pure predicate for the client (or a query object for a server seam), and round-trips through the URL with validate-and-repair so a shared link that references a since-removed field drops the stale rule instead of crashing.

On top of the pills sit the layers best-in-class products ship: faceted counts (count-annotated multi-selects, recomputed with the disjunctive two-query trick so OR-siblings don't zero out), a relative-date engine that ships rolling windows AND calendar buckets labelled distinctly (the reliability win — a rolling "past week" is 7 days; a calendar "this week" is Mon–Sun), first-class date between, saved views from an injectable store, nested AND/OR groups (one conjunction per group, ~3-level cap), and the 2026 headline — a natural-language Ask bar that returns editable chips, never a black-box answer. A FilterApi handle (addRule / setModel / fromNaturalLanguage / toQuery / …) is the seam an agent, an MCP tool, or the Ask parser drives.

Guidelines

Give it a field schema, and let type derive the rest. Each FilterField is { id, label, type, … } — the type picks the operator set and the value editor automatically. Reach for the escapes (operators to restrict by id, getOperators to derive functionally) only when the type defaults don't fit; the whole point is that impossible queries are unrepresentable.

Two ways to wire it. For a self-contained bar, pass fields (+ records for the live count) to <FilterBar> and it owns its own engine. For a data-table or CollectionView, create the engine yourself with useFilters and pass it via engine, then read engine.matches / engine.predicate to drive the rows — the bar and the table share one model.

A pending chip doesn't filter. A rule with no valid value renders neutral + dashed (data-state="pending") and is excluded from the predicate; it earns its field tone and starts filtering only once complete. That neutral rendering is deliberate — tone means "this condition is live."

Label rolling vs calendar dates for your users, and don't promise exact counts at scale. The engine ships both relative families distinctly on purpose. Faceted counts are the highest-leverage feature (they prevent zero-result dead-ends) but degrade to approximate on large sets — surface that, don't imply precision.

The URL is the source of truth for the active filter. Use the serialize/deserialize helpers (or the optional useFilterUrlState hook) so a filtered view is shareable and back-button-safe; saved views are named objects that set it.

Date rule values are yyyy-MM-dd calendar dates. The date editors (and the NL parser) commit zoneless yyyy-MM-dd strings — the same shape the engine's own asDate coercion local-parses back — never UTC ISO instants, so a stored or shared filter can't drift a day across timezones. Values you inject via addRule/setModel should use the same format (a Date or epoch number is also accepted on input).

Best practices

Do
  • Describe each column as a FilterField with the right type (text/number/date/select/multi/boolean/user) and let the operator set + value editor derive from it.
  • Pass records (client mode) so the result count and faceted counts compute; give date fields with a status pairing a deadline config to unlock overdue/upcoming operators.
  • Share one model with a table/CollectionView by creating the engine via useFilters and passing it as engine (read engine.matches / engine.predicate).
  • Persist and share the active filter through the URL (serialize/deserialize or useFilterUrlState); use SavedViews with an injectable store for named presets.
  • Drive it programmatically (agents, the Ask bar) through apiRef / the FilterApiaddRule, setModel, fromNaturalLanguage, toQuery.
Don't
  • Don't hand-author a rule value the operator can't validate — an is_within date needs a { preset } RelativeSpec, not a bare string; an invalid value renders as a (harmless) pending chip that doesn't filter.
  • Don't expect a pending chip (no value yet) to affect results — it's neutral/dashed and excluded until complete; that's the intended "not live" cue.
  • Don't override the chip roles to a listbox — interactive-child chips require the role=grid pattern.
  • Don't nest groups past the cap (default 3) — Add group grays out; prefer restructuring the query over ever-deeper nesting.
  • Don't promise an exact live count on very large datasets — the count/facets degrade to approximate at scale.

Troubleshooting

FilterBar renders nothing / throws — no chips, no add button.

Cause. Neither fields nor engine was supplied, so the bar has no schema to build from.

Fix. Pass fields={[…]} (the bar creates its own engine) OR pass an engine you made with useFilters(fields, opts). records is additionally needed for the live result count / facets.

A chip stays gray/dashed and doesn't affect the results.

Cause. The rule is pending — it has no valid value yet (e.g. an is_within date without a { preset }), so it's excluded from the predicate and rendered neutral by design.

Fix. Set a valid value for the operator's arity (a scalar for is, a { preset } RelativeSpec for is_within, an array for is_any_of). It turns tone-colored and starts filtering once isRuleComplete passes.

A chip icon (the field glyph, the caret, or the ✕) renders at 16px no matter what size-* I set.

Cause. The chip reuses tag's tagVariants, whose size variant sets [&_svg]:size-4 ON the chip — a descendant rule that forces every chip icon to 16px and wins same-specificity ties on source order.

Fix. Use Tailwind !important on the per-icon size (!size-3.5, etc.), per garn convention (cf. calendar.tsx). Without the ! it's a silent no-op.

A relative-date filter returns different rows than the user expected (off by a few days at week/month boundaries).

Cause. Rolling windows (past_week = rolling 7 days) and calendar buckets (this_week = calendar Mon–Sun) are different presets — picking the wrong family is the classic reliability trap.

Fix. Choose the labelled family deliberately: the rolling presets (last/next N days, past week/month/year) vs the calendar ones (this/last/next week/month/quarter/year, labelled "(calendar)"). Both ship on purpose.

The filter model won't change / the chips are frozen.

Cause. filters was passed without onFiltersChange, making the model read-only (controlled with no writer).

Fix. Pass both filters + onFiltersChange for controlled, or defaultFilters for uncontrolled. When adopting an engine, let the engine own the pair.

The result count doesn't render, or reads 0 when rows clearly match.

Cause. The count/facets are client-mode only — they need records (or an engine created with records). Without records the engine has nothing to evaluate.

Fix. Pass records={data} to <FilterBar> or to useFilters(fields, { records }). On very large sets, treat the count as approximate.

The Ask bar returns an answer/blob instead of chips, or I can't customize its parsing.

Cause. Expecting a black-box NL answer; the doctrine is "AI returns a query, not an answer" — it always emits editable chips via the engine's parseQuery.

Fix. Rely on the deterministic default parser, or inject parseQuery (the LLM/MCP seam) in useFilters opts — constrain the model to the JSON-Schema FilterModel so it can only emit valid rules, which render as correctable chips.

Accessibility

Role
grid
ARIA APG
Grid (editable chips) + Combobox/Listbox (authoring) + Status message (result count)
Focus
The chip grid holds one roving tab stop; the active chip carries tabIndex 0 and self-heals when chips are added/removed (focus lands on the removed chip's neighbour). Segment buttons are tabIndex -1 in the grid (reached by drill + arrows) and Tab-focusable standalone. Don't strip the focus rings.

Accessibility requirements

error

Chips carrying interactive controls must be a `role="grid"` with roving tabindex (row + gridcell segments), NOT a listbox. FilterBar.Chips does this; don't override the roles.

when the chip row renders editable pills with remove/segment buttons

error

Removing a chip must move focus to its neighbour (never lose focus to the body), so keyboard users stay in the bar.

when a chip is removed via Backspace/Delete or the ✕ button

error

The operator, value, and remove segments each need a descriptive accessible name that includes the field/value context (not a bare "button" or icon-only ✕).

when any chip is rendered

warn

The result count must be a `role="status"` (aria-live=polite) live region so the match count is announced as filters change.

when the live result count is shown

Keyboard

ArrowLeftArrowRightArrowUpArrowDownOn a chip row: rove to the previous/next chip. On a segment: move to the previous/next segment within the chip.
HomeEndFocus the first / last chip in the row.
EnterSpaceOn a chip row: drill in — focus the operator segment. On a segment: open its menu/editor.
EscapeFrom a segment: exit back to the chip row (or close an open menu/editor).
BackspaceDeleteOn a chip row: remove that filter and move focus to its neighbour.
A1.3.1Info and RelationshipsA2.1.1KeyboardAA2.4.7Focus VisibleA4.1.2Name, Role, ValueAA4.1.3Status Messages
  • The chip row is `role="grid"` with `aria-label="Active filters"`, a single tab stop, and roving focus — because each chip carries interactive segment + remove buttons, grid is the correct semantics (a listbox is wrong for interactive-child chips). Each chip is a `role="row"`; its field/operator/value/remove segments are `role="gridcell"`.
  • Keyboard model on the flat bar: arrows rove between chips (Home/End jump); Enter/Space drills into a chip (focus its operator segment); arrows then move between segments and Escape exits back to the row; Backspace/Delete removes the focused chip and moves focus to its neighbour. Standalone chips inside a nested group are `role="group"` and their buttons are directly Tab-focusable.
  • Every interactive segment has a descriptive accessible name: the operator button `Operator: <label>. Change`, the value button `Value: <summary>. Change` (or `Set value` while pending), the remove button `Remove filter: <field> <operator> <value>`, the conjunction `Match all/any of the filters — change`.
  • The result count is a `role="status"` aria-live=polite region so screen readers hear the match count update as filters change; the loading state is also role=status (keep-previous, no empty flash) and the error state is role=alert with a labelled retry.
  • Authoring surfaces (the `+ Filter` field menu, the operator menu, select/multi/user/relative-date value editors) are Command comboboxes/listboxes with a text filter, following the WAI-ARIA combobox pattern; the natural-language Ask input has aria-label `Ask in natural language` and announces `Filters updated` on parse.