Filter builder
ProNested AND/OR conditions over typed fields: text, number, date, select and yes/no, each with its own operators. The value is plain JSON, with helpers to apply it to rows and describe it in a sentence.
npx prodular-ui add filter-builderPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Filter button over a list
FilterBuilderPopover in a toolbar. applyFilter narrows the rows and describeFilter writes the sentence next to the button.
Status is any of Open or Overdue and Amount is at least 5,000 USD
- INV-2057Ironclad SecurityOverdue$18,647
- INV-2041Northwind LogisticsOverdue$5,900
- INV-2038Kestrel HealthOpen$8,420
3 of 7 invoices
"use client"
import * as React from "react"import { BuildingIcon, CalendarIcon, CircleDotIcon, DollarSignIcon, RepeatIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"import { FilterBuilderPopover, applyFilter, describeFilter, type FilterField, type FilterGroup,} from "@/components/ui/filter-builder"
type Invoice = { id: string; customer: string; status: string; amount: number; due: string; autopay: boolean }
const invoices: Invoice[] = [ { id: "INV-2095", customer: "Meridian Freight", status: "draft", amount: 622, due: "2026-11-19", autopay: false }, { id: "INV-2079", customer: "Orchard Lane Grocers", status: "open", amount: 2394, due: "2026-11-14", autopay: true }, { id: "INV-2066", customer: "Halcyon Robotics", status: "paid", amount: 11227, due: "2026-11-10", autopay: true }, { id: "INV-2057", customer: "Ironclad Security", status: "overdue", amount: 18647, due: "2026-10-10", autopay: false }, { id: "INV-2041", customer: "Northwind Logistics", status: "overdue", amount: 5900, due: "2026-10-03", autopay: false }, { id: "INV-2038", customer: "Kestrel Health", status: "open", amount: 8420, due: "2026-11-02", autopay: false }, { id: "INV-2031", customer: "Lumen Analytics", status: "paid", amount: 3591, due: "2026-10-06", autopay: true },]
const statuses = [ { value: "draft", label: "Draft" }, { value: "open", label: "Open" }, { value: "overdue", label: "Overdue" }, { value: "paid", label: "Paid" },]
const fields: FilterField<Invoice>[] = [ { id: "status", label: "Status", type: "select", options: statuses, icon: <CircleDotIcon /> }, { id: "customer", label: "Customer", type: "text", icon: <BuildingIcon /> }, { id: "amount", label: "Amount", type: "number", unit: "USD", icon: <DollarSignIcon /> }, { id: "due", label: "Due date", type: "date", icon: <CalendarIcon /> }, { id: "autopay", label: "Autopay", type: "boolean", icon: <RepeatIcon /> },]
const today = new Date(2026, 9, 22)const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })const tone = { draft: "secondary", open: "info", overdue: "destructive", paid: "success" } as const
export default function FilterBuilderInvoices() { const [filter, setFilter] = React.useState<FilterGroup>({ id: "root", combinator: "and", conditions: [ { id: "c1", field: "status", operator: "any_of", value: ["open", "overdue"] }, { id: "c2", field: "amount", operator: "gte", value: 5000 }, ], }) const rows = applyFilter(invoices, filter, fields as FilterField<Invoice>[], today) const sentence = describeFilter(filter, fields as FilterField[])
return ( <div className="flex w-full max-w-2xl flex-col gap-3"> <div className="flex flex-wrap items-center gap-3"> <FilterBuilderPopover fields={fields as FilterField[]} value={filter} onValueChange={setFilter} /> <p className="min-w-0 flex-1 truncate text-[13px] text-muted-foreground">{sentence || "All invoices"}</p> </div> <div className="overflow-hidden rounded-xl border bg-card"> <ul className="divide-y text-sm"> {rows.map((r) => ( <li key={r.id} className="flex h-11 items-center gap-3 px-4"> <span className="w-20 font-mono text-[13px] text-muted-foreground">{r.id}</span> <span className="min-w-0 flex-1 truncate">{r.customer}</span> <Badge variant={tone[r.status as keyof typeof tone]} size="sm"> {statuses.find((s) => s.value === r.status)?.label} </Badge> <span className="w-20 text-right font-medium tabular-nums">{usd.format(r.amount)}</span> </li> ))} {rows.length === 0 && <li className="flex h-24 items-center justify-center text-[13px] text-muted-foreground">No invoices match these filters.</li>} </ul> </div> <p className="text-[13px] text-muted-foreground tabular-nums"> {rows.length} of {invoices.length} invoices </p> </div> )}Nested groups
An inline builder for a customer segment, with an OR group inside the AND and a live count of matching accounts.
Expansion candidates
Accounts for the Q4 upgrade campaign
63 of 240 accounts
Plan is any of Team or Enterprise and (Seats is at least 50 or MRR is at least 5,000 USD) and Last active is in the last 14 days
"use client"
import * as React from "react"
import { FilterBuilder, applyFilter, describeFilter, type FilterField, type FilterGroup,} from "@/components/ui/filter-builder"
const plans = [ { value: "starter", label: "Starter" }, { value: "pro", label: "Pro" }, { value: "team", label: "Team" }, { value: "enterprise", label: "Enterprise" },]
const fields: FilterField[] = [ { id: "plan", label: "Plan", type: "select", options: plans }, { id: "seats", label: "Seats", type: "number" }, { id: "mrr", label: "MRR", type: "number", unit: "USD" }, { id: "lastActive", label: "Last active", type: "date" }, { id: "country", label: "Country", type: "text" },]
const accounts = Array.from({ length: 240 }, (_, i) => ({ plan: plans[(i * 7) % 4].value, seats: 5 + ((i * 37) % 180), mrr: 200 + ((i * 613) % 14000), lastActive: `2026-10-${String(1 + ((i * 11) % 21)).padStart(2, "0")}`, country: ["Germany", "United States", "Poland", "Japan"][i % 4],}))
export default function FilterBuilderSegment() { const [filter, setFilter] = React.useState<FilterGroup>({ id: "root", combinator: "and", conditions: [ { id: "c1", field: "plan", operator: "any_of", value: ["team", "enterprise"] }, { id: "g1", combinator: "or", conditions: [ { id: "c2", field: "seats", operator: "gte", value: 50 }, { id: "c3", field: "mrr", operator: "gte", value: 5000 }, ], }, { id: "c4", field: "lastActive", operator: "last_days", value: 14 }, ], }) const matched = applyFilter(accounts, filter, fields, new Date(2026, 9, 22)).length
return ( <div className="flex w-full max-w-3xl flex-col gap-4"> <div className="flex items-baseline justify-between gap-4"> <div> <h3 className="text-sm font-semibold">Expansion candidates</h3> <p className="text-[13px] text-muted-foreground">Accounts for the Q4 upgrade campaign</p> </div> <p className="text-sm tabular-nums"> <span className="font-semibold">{matched}</span> <span className="text-muted-foreground">of {accounts.length} accounts</span> </p> </div> <FilterBuilder fields={fields} value={filter} onValueChange={setFilter} /> <p className="border-t pt-3 text-[13px] text-pretty text-muted-foreground">{describeFilter(filter, fields)}</p> </div> )}Anatomy
const [filter, setFilter] = useState(createFilter())
<FilterBuilderPopover fields={fields} value={filter} onValueChange={setFilter} />
const rows = applyFilter(invoices, filter, fields)Guidance
Use it when
- Lists and reports where people combine conditions across fields, like overdue invoices over $5,000 for one region.
- Building segments, audiences and automation rules that are saved and run later.
- FilterBuilderPopover in a table toolbar; the inline FilterBuilder on a page or in a dialog where the filter is the main task.
Avoid
- One or two fixed filters on a single field. Use the faceted filters in Data table.
- Free text search. Use Search input and keep the builder for structured conditions.
- Groups deeper than one level. maxDepth=1 covers almost every real query.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Describe each filterable column once as a FilterField with id, label and type. Give select fields their options, and number fields a unit when it helps.
- Keep the value in state as FilterGroup JSON and pass value with onValueChange. Store it as is in saved views or the URL.
- Filter rows on the client with applyFilter(rows, filter, fields). On the server, walk the same JSON into your query; operators map one to one to SQL.
- Show describeFilter(filter, fields) next to the trigger or in a saved view so people can read the filter without opening it.
- To let a model build filters, give it the field list and the FilterGroup shape and ask for JSON. Validate field ids and operators before applying.
- Incomplete conditions are ignored by applyFilter and countConditions, so never block typing with validation.
API
FilterBuilder
| Prop | Type | Default | Description |
|---|---|---|---|
| fields | FilterField[] | Filterable fields: { id, label, type, options?, unit?, icon?, accessor? }. | |
| value / onValueChange | FilterGroup / (value) => void | The filter tree, controlled. | |
| maxDepth | number | 1 | How deep groups can nest. 0 hides Add group. |
FilterBuilderPopover
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | "Filter" | Trigger text. The trigger shows the count of active conditions. |
applyFilter
| Prop | Type | Default | Description |
|---|---|---|---|
| (rows, filter, fields, today?) | TRow[] | Rows that pass. Pass today to make date conditions deterministic. |
describeFilter
| Prop | Type | Default | Description |
|---|---|---|---|
| (filter, fields) | string | The filter as one sentence. |
countConditions
| Prop | Type | Default | Description |
|---|---|---|---|
| (filter) | number | Complete conditions, nested groups included. |
createFilter
| Prop | Type | Default | Description |
|---|---|---|---|
| (combinator?) | FilterGroup | An empty filter. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add filter-builder# or, with the registry in components.jsonnpx shadcn@latest add @prodular/filter-builder