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.
@garn/ui/filtersOn 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.
npx garn-ui add filters- 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
| Selector | State | Description |
|---|---|---|
[data-state=complete] | complete | A rule with a valid value — it applies to the predicate and the chip shows its field tone. |
[data-state=pending] | pending | A rule mid-authoring (no valid value yet) — rendered neutral + dashed, and NOT applied until it completes. The reusable "not affecting results" cue. |
[data-slot=filter-group] | nested | A nested AND/OR group — a group-outline box with its own conjunction; the whole bar switches from the roving grid to the querybuilder tree. |
[data-slot=filter-loading] | loading | Async option/count fetch in flight — keep-previous, no empty flash; announced via role=status. |
[data-slot=filter-error] | error | A failed option/count fetch — role=alert + optional retry. |
[data-slot=filter-empty] | empty | No filters / no matches — only the add + Ask affordances remain. |
| Property | Token | Tier |
|---|---|---|
gap | --garn-gap-inline | semantic · 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
- Describe each column as a
FilterFieldwith the righttype(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 adeadlineconfig to unlock overdue/upcoming operators. - Share one model with a table/CollectionView by creating the engine via
useFiltersand passing it asengine(readengine.matches/engine.predicate). - Persist and share the active filter through the URL (
serialize/deserializeoruseFilterUrlState); useSavedViewswith an injectable store for named presets. - Drive it programmatically (agents, the Ask bar) through
apiRef/ theFilterApi—addRule,setModel,fromNaturalLanguage,toQuery.
- Don't hand-author a rule value the operator can't validate — an
is_withindate 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 groupgrays 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
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
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
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
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
| ArrowLeftArrowRightArrowUpArrowDown | On a chip row: rove to the previous/next chip. On a segment: move to the previous/next segment within the chip. |
| HomeEnd | Focus the first / last chip in the row. |
| EnterSpace | On a chip row: drill in — focus the operator segment. On a segment: open its menu/editor. |
| Escape | From a segment: exit back to the chip row (or close an open menu/editor). |
| BackspaceDelete | On a chip row: remove that filter and move focus to its neighbour. |
- 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.