The advanced-search panel: rows of "field, operator, value" stacked up, joined with AND or OR, optionally one level of grouping, and three ways out — the tree the editor holds, the JSON to send a server, and the condition as a sentence. Reach for it wherever a list page has outgrown a search box and a few dropdowns: the advanced filter of an admin table, a segment or audience builder, a cohort definition, the conditions on a report or export, filtering an audit or activity log, a saved or shared view, the "matches these rules" half of an automation, an alert or monitor rule, feature-flag targeting, an access or permission rule, a smart playlist or smart folder, a CRM or help-desk list filter, a product-catalogue facet editor for staff, and the filter a person expects to be able to put in a link and send to a colleague. Common asks it answers: "filter builder react", "query builder react", "react-querybuilder alternative", "shadcn filter builder", "shadcn query builder", "advanced filter component react", "condition builder react", "rule builder react", "and or filter ui react", "nested filter groups react", "airtable style filter ui", "notion filter ui react", "segment builder ui", "build where clause ui", "filter builder tailwind", "operator dropdown by field type", "which operators for which data type filter", "dynamic operator list react", "value input depends on operator", "between operator two inputs react", "is empty operator no input", "in operator multiple values ui", "filter tree data structure typescript", "serialize filter to url query string", "encode filter in url react", "parse filter from search params", "filter state in url nextjs", "shareable filter link", "filter to sql where clause json", "send filter to api json", "incomplete filter row validation", "filter row missing value", "why does my filter return wrong count", "validate number input filter", "validate date input filter", "date range reversed validation", "accessible filter builder", "aria labels filter builder", "絞り込み 条件 ビルダー react", "検索条件 作成 ui", "and or 条件 入れ子", "条件ビルダー 演算子 型ごと", "フィルタ条件 url 共有", "管理画面 高度な検索". Five things decide the shape of this component, and each one is a way the eighty-line version is quietly wrong. The operator menu belongs to the field's kind, not to the component. A string has no "is greater than" and a date has no "contains", so changing the field rebuilds the operator list and, where the kept operator takes different slots, the value editor too. The id stays the server's vocabulary while the label follows the kind: `gt` reads "is greater than" over a number and "is after" over a date, because a person reading "greater than" above a date picker has to translate. The value editor is the operator's arity, and the arity is load-bearing. `is between` holds two, `is any of` holds a list that can grow, `is empty` holds none. The part that is easy to get wrong is narrowing: an operator moved from `is between` back to `is greater than` must *drop* the second value, not keep it out of sight — a rule that sends `gt` with `["10","20"]` hands a server a bound that nothing will use and everything is entitled to. A field change is not only an operator change. Values carry over between fields of the same kind and are discarded between kinds, because "acme" surviving a move from Company to Amount makes a row that reads as complete and filters on a number that is not one; moving between two enums replaces a value the new option list does not offer rather than leaving one it will never match. What counts as filled in cannot be left to the language. `Number("")` is `0` and `Number(" ")` is `0`, so a numeric check written with it reads an empty box as a filter on zero; `Number("0x10")` is 16 and `Number("1e3")` is 1000. `Date.parse("2026-02-30")` answers the 2nd of March rather than failing, which moves the boundary the person typed, and `Date.parse("42")` answers a year. So numbers are a strict decimal and dates are a `YYYY-MM-DD` built and read back part by part. A range typed backwards is a separate state from an unfinished one: it is complete, it matches nothing, and it says so beside itself instead of being thrown away. Rows that are not finished come back with the query instead of vanishing from it. `buildFilterQuery` returns `{ query, dropped }`, and the second half is the one that is usually missing: a builder that silently skips the row somebody was halfway through returns a result set that does not match what is on screen, and that is read as the search being broken rather than as a row nobody completed. A query of `null` means "nothing to filter on", which a caller must not confuse with a filter that matches nothing. Three further decisions, each deliberate. One conjunction per group, not per row — a select on every row invites `A and B or C`, which has no single reading, so the second row carries the group's select and the rest repeat it as text. Nesting stops at one level by default (`maxDepth`), because unlimited nesting is how a saved filter becomes one nobody can interpret later, and a new group opens on the opposite conjunction since a nested group repeating its parent's join changes nothing about what is selected. A group that loses its last row is removed with it, since an empty group matches everything and reads as a box with no condition in it. The three outputs are the same tree seen from three places, which is the other half of why this is a component and not a form. `toFilterParam` / `parseFilterParam` round-trip the editor's own state — unfinished rows included, so a draft survives a reload — in a form that is readable in a shared link: `and(status:eq:open,amount:gte:100)`. Six characters are structural there and every one of them can appear in a value, so segments are escaped: a product called `A, B (2kg)` survives the trip, and a string that cannot be read loads nothing at all rather than half a filter applied silently. `buildFilterQuery` is the pruned shape for a server, with single-child groups collapsed because `and(x)` and `x` select the same rows. `describeFilter` is the condition as a sentence, using the field labels and the kind's own operator wording, for a live region, a summary line or a chip. Smaller things that are still decisions: a number is a text input with `inputMode="decimal"` rather than `type="number"`, because the native number input discards what it cannot parse before the component ever sees it, so a half-typed `1.` or a pasted `1,5` becomes an empty box and the row reports a value that was never missing. The kinds drawn as a select (enum, boolean) start on a legal value instead of blank, so a row cannot show "Open" while reporting "Enter a value". Every control is named for the row it is in, an unusable row marks its own box `aria-invalid` and points at the reason through `aria-describedby` rather than announcing it into a live region while it is still being typed into, and adding or removing a row is announced through one polite `sr-only` region. Official shadcn/ui has nothing of the kind, measured today rather than remembered. Fetching every entry in its registry — 65 listed, 62 fetchable, `code-block`, `questionnaire` and `trace` 404 on both style tracks — and searching all 223,287 bytes of it, `operator`, `predicate`, `conjunction`, `filterGroup` and `queryBuilder` are every one of them a zero hit. `filter` returns four, and they are worth naming rather than rounding to zero: all four are `Array.prototype.filter` inside the chart and toast internals. The check that actually settles it is the name list rather than the word count, because official can ship a component whose source says nothing about what it does: no entry among the 65 is a filter, query or condition builder of any kind. Within pulld this is the first component with an operator in it. `filter-chips` is the display side — the pills above a list, derived from the query string, with a × that takes one condition off — and the two are made to be used together: build here, show there. `search-input` and `command-palette` are free text, `cron-expression` is the other structured editor (a different grammar, the same idea of editing a machine-readable value through controls), and `sort-header` is the ordering rather than the filtering. None of them composes into this one, so it has no `registryDependencies`. The API: `fields` describes what can be filtered (`name`, `label`, `kind`, `options`, and an optional `operators` narrowing whose order becomes the menu's); `value` / `defaultValue` / `onChange` make it controlled or not; `maxDepth`, `maxRules`, `legend`, `labels` and `disabled` are the rest. The logic is exported as plain functions so the parts that matter can be used without rendering anything: `operatorsForField`, `operatorLabel`, `retargetRule`, `retargetOperator`, `coerceValues`, `ruleIssue`, `ruleIsComplete`, `buildFilterQuery`, `toFilterParam`, `parseFilterParam`, `describeFilter`, `isPlainNumber` and `isPlainDate`. Every label is replaceable one at a time for another language. One file, two lucide icons, your `cn`, and no query-builder library.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/filter-builder.json"