npx shadcn@latest add https://sdk-components.thesqd.com/r/filter-builder.jsonimport { FilterBuilder } from "@/components/blocks/filter-builder";"use client";
import * as React from "react";
import { FilterBuilder } from "@/components/blocks/filter-builder";
const FIELDS = [
{ id: "title", label: "Title" },
{
id: "status",
label: "Status",
options: [
{ value: "queued", label: "Queued" },
{ value: "delivered", label: "Delivered" },
],
},
];
const PROJECTS = [
{ id: "1", title: "Stewardship campaign brief", status: "queued" },
{ id: "2", title: "Annual report cover", status: "delivered" },
];
export function Example() {
const [rows, setRows] = React.useState(PROJECTS);
return (
<>
<FilterBuilder fields={FIELDS} records={PROJECTS} onFilteredChange={setRows} />
{rows.map((row) => <div key={row.id}>{row.title}</div>)}
</>
);
}
FilterBuilder
├── Filters button (default) — FunnelSimple icon + active-clause count badge
│ └── Popover → the panel
└── inline — the panel with no trigger
Panel (one group per nesting level)
├── "Where" lead on the first row
├── Conjunction <Select> on the SECOND row only — and / or, owned by the
│ group, echoed as plain text on every row after it
├── Clause row
│ ├── drag handle (reveals on hover — reorders within the group)
│ ├── field <Select>
│ ├── operator <Select> is / is not / contains
│ ├── value <Select> when the field has options or groups (given, or
│ │ derived from `records`); `groups` draws a heading
│ │ per group …
│ ├── value <TextField> … otherwise a free-text value
│ └── remove ✕
├── Nested group row — one level, for `A and (B or C)`
└── Actions: Add filter · Add group (root only) · Clear all (destructive)
The popover panel tweens its height as rows come and go (transitions.dev card
resize), and with no clauses it shrinks to just the two buttons.
Data model
FilterGroup { id, conjunction: "and" | "or", children: FilterNode[] }
FilterNode = FilterClause | FilterGroup
FilterClause { id, fieldId, operator?, value }
Evaluation (separate from the UI — the builder never touches your data)
├── filterRecords(records, group, fields) → the rows that pass
├── matchesFilterGroup(record, group, fields) → one record
├── countFilters(group) → the clauses that actually filter (the badge's number)
└── toFilterGroup(clauses | group | json) → normalizes any of them into a group
Persisting a view
├── filterToJson(group) → the tree as a JSON string (URL, localStorage, a column)
├── parseFilterJson(json) → the tree back; anything malformed returns an empty group
└── `onJsonChange` / `defaultValue={json}` do both without the helpers
Matching rules
├── An array value tests membership; anything else compares as text.
├── A field with options accepts either the stored `value` or its `label`.
├── `contains` is case-insensitive substring.
└── An empty clause value and an empty group are no-ops, not walls.FilterBuilder
| Prop | Type | Default | Description |
|---|---|---|---|
fields | FilterField[] | — | Fields a clause can test. A new clause starts blank — the user picks the field and value. |
value | FilterGroup | — | Controlled filter tree. Pair with `onValueChange`. |
defaultValue | FilterClause[] | FilterGroup | string | — | Uncontrolled initial tree. A flat clause list is joined with `and`, and a JSON string (what `onJsonChange` hands back) is parsed — a malformed one falls back to an empty tree instead of throwing. |
onValueChange | (next: FilterGroup) => void | — | Fired with the whole tree after every edit, reorder or removal. |
onJsonChange | (json: string) => void | — | The same change as `onValueChange`, already serialised — hand it straight back as `defaultValue` to restore the view. |
records | Record<string, unknown>[] | — | The records being filtered. Read to derive value options for fields that declare none (first 60 distinct values), and to compute `onFilteredChange`. |
onFilteredChange | (rows: T[]) => void | — | The records that pass the current tree, handed over whenever it changes. Use `filterRecords` instead when the tree drives something else (a server query, another view). |
inline | boolean | false | Render the panel bare instead of behind a Filters button. |
label | string | "Filters" | Trigger label. |
width | number | 520 | Resting panel width in px, capped at the viewport. |
maxWidth | number | 760 | How wide the panel may grow for a long value before it truncates instead. Capped at the viewport. |
className | string | — | Applied to the trigger, or to the inline panel wrapper. |
FilterField
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Key read off each record. |
label | string | — | Shown in the field select. |
options | { value: string; label: string }[] | — | The field's vocabulary. Renders a value select and narrows the operators to `is` / `is not`. Omit for a free-text field. |
groups | { label: string; options: { value: string; label: string }[] }[] | — | The same vocabulary split under headings, drawn in the given order (e.g. people under their team, then "Former staff"). A field with only `groups` is an option field: it gets a grouped value select, `is` / `is not`, and label lookups across every group. When both are set, `options` wins. |
operators | ("is" | "is not" | "contains")[] | — | Override the offered operators. Defaults to all three, or `is` / `is not` when the field has options. |
FilterClause
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Stable clause id — also the drag key. |
fieldId | string | — | The `FilterField.id` this clause tests. |
operator | "is" | "is not" | "contains" | "is" | How the value is compared. |
value | string | — | The value tested against. An empty value is a no-op, so a half-written clause hides nothing. |