# Filter Builder

> A React filter builder for any list: condition chips, AND / OR, nested groups, saved views and a live count, with one bottom sheet on phones.

- Type: React component (Inputs)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add filter-builder`
- Docs: https://wingo-ui.com/components/filter-builder

## Interaction

Press + Filter (or the shortcut, shift+F in the demo) for a searchable, grouped list of fields: type to narrow it, Up and Down to move, Enter to pick. The new chip appears with its editor open, the operator already chosen and focus in the value: text applies as you type (after a short pause) and on Enter, numbers and amounts through their fields, dates on an inline calendar with quick ranges, choices in a list with search, checkboxes and Select all. Close an editor (Escape, a click outside) while its condition is incomplete, or after emptying it, and the chip goes away, so no chip ever filters nothing. Click a chip to edit it; its × removes it. The chips row is a toolbar with one Tab stop: Left and Right move between chips, the and / or toggle, + Filter and Clear, Home and End jump to the ends, Enter or Space opens a chip, Delete or Backspace removes it and focus moves to the next chip. The and / or between the first two chips switches the whole filter. A nested group shows as one chip with its sentence and opens the panel, where every condition is a row (field, operator, value, remove): Add condition and Add group put focus in the new row, Enter in a text or number value applies at once (or applies the draft and closes with applyMode apply), and removing a row from the keyboard moves focus to the next one. The views menu lists saved views (pinned first) and All records, Up and Down move through them; changing a view's filter marks it modified and offers Save changes or Save as new view, with Rename and Delete in each view's ⋯ menu, and a save the app rejects keeps the name and says so. The result count updates in a polite live region. With applyMode apply, editors keep a draft until Apply. On a phone a Filters button with the count opens one bottom sheet: the conditions as rows with a ⋯ for Edit and Remove, and / or as a segmented control, + Add filter drills into the field list and then the editor with a short slide (long lists scroll under their search), the back arrow returns, focus lands on each page's title, and nothing changes until Show 24 results; closing the sheet discards the draft. The chips under it scroll sideways and their × removes at once.

## Installation

```bash
npx wingo-ui@latest add filter-builder
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/filter-builder` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/filter-builder.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Calendar](https://wingo-ui.com/components/calendar) ([Markdown](https://wingo-ui.com/components/calendar.md))
- [Currency Input](https://wingo-ui.com/components/currency-input) ([Markdown](https://wingo-ui.com/components/currency-input.md))
- [Date Picker](https://wingo-ui.com/components/date-picker) ([Markdown](https://wingo-ui.com/components/date-picker.md))
- [Date Range Picker](https://wingo-ui.com/components/date-range-picker) ([Markdown](https://wingo-ui.com/components/date-range-picker.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Input](https://wingo-ui.com/components/input) ([Markdown](https://wingo-ui.com/components/input.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [Number Input](https://wingo-ui.com/components/number-input) ([Markdown](https://wingo-ui.com/components/number-input.md))
- [Popover](https://wingo-ui.com/components/popover) ([Markdown](https://wingo-ui.com/components/popover.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Select](https://wingo-ui.com/components/select) ([Markdown](https://wingo-ui.com/components/select.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [Filters](https://wingo-ui.com/components/filters) ([Markdown](https://wingo-ui.com/components/filters.md))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useMemo, useState } from "react"
import { FilterBuilder, type FilterBuilderField, type FilterGroup, type FilterView } from "@/components/ui/filter-builder"
import { applyFilter, createFilterCondition, createFilterGroup } from "@/lib/filters"

type Client = { name: string; city: string; balance: number }

const FIELDS: FilterBuilderField<Client>[] = [
  { id: "name", label: "Client", type: "text" },
  { id: "city", label: "City", type: "select", options: [{ value: "Chicago", label: "Chicago" }, { value: "Austin", label: "Austin" }] },
  { id: "balance", label: "Balance", type: "currency", currency: "USD" },
]

const VIEWS: FilterView[] = [
  { id: "chicago-austin", name: "Chicago and Austin", filter: createFilterGroup("and", [createFilterCondition(FIELDS[1], { value: ["Chicago", "Austin"] })]) },
]

export function ClientFilters({ clients }: { clients: Client[] }) {
  const [filter, setFilter] = useState<FilterGroup>(() => createFilterGroup("and"))
  const rows = useMemo(() => applyFilter(clients, filter, FIELDS), [clients, filter])
  return (
    <FilterBuilder
      fields={FIELDS}
      value={filter}
      onValueChange={setFilter}
      views={VIEWS}
      resultCount={rows.length}
      locale="en-US"
      shortcut="shift+f"
      labels={{ allRecords: "All clients" }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `fields` | `FilterBuilderField<T>[]` |  | The filterable fields: { id, label, type: text \| number \| currency \| date \| select \| multi-select \| boolean, accessor?, options?, operators?, currency?, placeholder?, group?, icon? }. Options carry avatars or color dots for people and statuses. |
| `variant?` | `"chips" \| "panel"` | `"chips"` | chips: inline chips, each with its own editor popover. panel: one Filters button with a count that opens a rows editor (Airtable / Notion style). |
| `size?` | `"sm" \| "md"` | `"md"` | Chips and bar buttons 28 / 32px with a 44px hit area on touch screens; the chip editors' fields follow (the panel keeps compact sm rows, the phone sheet full-size ones). |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"full"` | The chips' and bar buttons' corners, from the button's radius map; full makes pills. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The color of active chips, the count badge, the calendar selection and the Apply buttons. Neutral chips use the foreground tint, other tones their soft fill. |
| `maxChips?` | `number` | `0` | Desktop: chips past this number collapse into a +3 chip that opens the panel. Phones always scroll through all of them. |
| `responsive?` | `boolean` | `true` | Below 768px a Filters button opens one bottom sheet with a draft and a Show 24 results button; off, phones get the desktop chips and popovers. |
| `defaultOpen?` | `boolean` | `false` | Starts with the panel (desktop) or the sheet (phones) open. |
| `disabled?` | `boolean` | `false` | Dims the builder; nothing opens and chips cannot be removed. |
| `loading?` | `boolean` | `false` | A request is running (server-side filtering): the count shows a spinner and dims until it settles. |
| `applyMode?` | `"instant" \| "apply"` | `"instant"` | instant: every complete change calls onValueChange (text after a 300ms pause). apply: chip editors and the panel keep a draft with Cancel / Apply. The phone sheet always drafts. |
| `allowOr?` | `boolean` | `true` | The root can switch between and / or (the toggle between the first two chips); off keeps every filter AND. |
| `allowGroups?` | `boolean` | `true` | Add group in the panel and the sheet: one level of nesting, like (Balance ≥ $5,000 or Last invoice in the last 7 days). |
| `shortcut?` | `string` | `""` | A key combo in mod+k notation that opens the field list (chips), the panel, or the sheet on phones. None by default; the demo uses "shift+f". Shown in the + Filter tooltip. |
| `locale?` | `string` | `""` | Formats money, numbers and dates in the chips and editors; the UIProvider's or the browser's when empty. The demo uses en-US. |
| `value?` | `FilterGroup` |  | The filter, controlled: { id, combinator: and \| or, conditions: (FilterCondition \| FilterGroup)[] }. Pair it with onValueChange. |
| `defaultValue?` | `FilterGroup` |  | The starting filter while uncontrolled; an empty AND group when left out. |
| `onValueChange?` | `(filter: FilterGroup) => void` |  | Every applied change. Filter rows with applyFilter from lib/filters, or send serializeFilter(filter) to the server. |
| `views?` | `FilterView[]` |  | Saved views in the views menu, pinned first. Built-in views are readOnly (no Rename or Delete). |
| `activeViewId?` | `string \| null` |  | The active view, controlled; picking a view replaces the filter, editing it afterwards shows (modified). |
| `defaultActiveViewId?` | `string \| null` | `""` | The starting view while uncontrolled; none (All records) when left out. Pair it with a value that holds the view's filter, or the trigger shows (modified). The playground offers the demo's views: overdue, mine, chicago-austin. |
| `onActiveViewChange?` | `(id: string \| null) => void` |  | A view was picked, saved, or All records chosen (null). |
| `onSaveView?` | `(input: { name: string; filter: FilterGroup; id?: string }) => (FilterView \| Promise<FilterView>)` |  | Enables saving: a name field in the views menu and in the panel. id is set for Save changes on the active view. The returned view becomes active; a rejected promise keeps the name field and announces labels.saveError. |
| `onRenameView?` | `(id: string, name: string) => (void \| Promise<unknown>)` |  | Adds Rename to each saved view's ⋯ menu: the name turns into a field, saved on Enter; a rejection keeps it open with labels.saveError. |
| `onDeleteView?` | `(id: string) => (void \| Promise<unknown>)` |  | Adds Delete to each saved view's ⋯ menu. Deleting the active view keeps its filter on screen; a rejection announces labels.deleteError. |
| `resultCount?` | `number` | `0` | How many rows the filter shows: next to the chips (labels.count) in a polite live region, and in the sheet's apply button while the draft equals the filter. The code has no default: leave it out to hide the count, and 0 is a real count ("0 results"). In the playground 0 means the table's own count; drag it for a server-sized total (1 client, 24 clients). |
| `previewCount?` | `(filter: FilterGroup) => (number \| undefined)` |  | The rows a draft would show, so the sheet's button reads Show 3 results before anything is applied (client-side lists pass applyFilter(...).length). |
| `open?` | `boolean` |  | The panel (desktop) or the sheet (phones), controlled. |
| `onOpenChange?` | `(open: boolean) => void` |  | The panel or the sheet opened or closed. |
| `now?` | `Date \| number` |  | Today for the quick date ranges (Last 7 days) and the calendars' first month. The demo pins 24.09.2026. |
| `labels?` | `Partial<FilterBuilderLabels>` |  | Every visible string, English by default (DEFAULT_FILTER_BUILDER_LABELS): add, addFilter, addCondition, addGroup, where, and, or, clear, apply, cancel, results(n, f), count(n, f), searchFields, noFields, value, from, to, days, views, allRecords, saveView, saveChanges, saveAsNew, viewName, modified, deleteView, renameView, save, saveError, deleteError, remove(text), removeGroup, removeCondition, title, group, more(n, f), switchCombinator, field, operator, back, edit, actions, search, selectAll, noResults, empty, rangePresets, operators (per operator) and describe (the lib/filters sentence labels). |
| `classNames?` | `Partial<Record<"trigger" \| "views" \| "chips" \| "chip" \| "combinator" \| "more" \| "add" \| "clear" \| "count" \| "popover" \| "fieldList" \| "panel" \| "row" \| "group" \| "sheet", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root, merged last. Other props (id, aria-*, data-*, handlers) spread onto it. |

## Parts

### FilterChip (component)

One condition as a chip with its editor, for toolbars that place chips themselves: condition, fields, onConditionChange, onRemove, onEdit, open / defaultOpen / onOpenChange, isNew, applyMode, responsive (true: below 768px the editor is a bottom sheet that keeps a draft until Apply), size, radius, tone, disabled, locale, now, labels, prefix, buttonProps, classNames. Props: FilterChipProps<T>.

### FilterConditionEditor (component)

The field / operator / value editor of one condition: layout stacked (popover, sheet) or inline (a panel row), density menu or sheet, onConditionChange, onSubmit (Enter). Props: FilterConditionEditorProps<T>.

### DEFAULT_FILTER_BUILDER_LABELS (constant)

The English labels; spread them to translate a few. Type: FilterBuilderLabels.

### FilterBuilderField (type)

A field of the builder. The lib/filters types it uses (FilterGroup, FilterCondition, FilterView, FilterOperator…) are re-exported. Shape: FilterField<T> & { icon?: ReactNode }.

## Examples

- **Panel with a nested OR group**: One Filters button opens a rows editor: Where / and / or, field, operator, value, and a group for (Balance ≥ $5,000 or Last invoice in the last 7 days).
- **Inside a toolbar**: The panel variant as the toolbar's filterTrigger; the conditions show on the toolbar's applied row.
- **FilterChip on its own**: One condition as a chip, for rows a page lays out itself: tap it to edit (a bottom sheet with Apply on phones), × removes it.
- **Apply mode**: Each editor keeps a draft with Cancel / Apply, for lists where every change costs a request.
- **Server mode**: onValueChange sends serializeFilter to a fake endpoint (600ms); the count shows a spinner until it answers.
- **Sizes, radius and the brand tone**: 28px chips with a 44px touch target, and brand-tinted chips on an OR filter with a range.
- **Phone**: Open it on a phone (or the device frame): chips scroll sideways, Filters opens one bottom sheet with a draft.

## See also

- [Filters](https://wingo-ui.com/components/filters) ([Markdown](https://wingo-ui.com/components/filters.md))
- [Toolbar](https://wingo-ui.com/components/toolbar) ([Markdown](https://wingo-ui.com/components/toolbar.md))
- [Data Table](https://wingo-ui.com/components/data-table) ([Markdown](https://wingo-ui.com/components/data-table.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [KPI Dashboard](https://wingo-ui.com/components/kpi-dashboard) ([Markdown](https://wingo-ui.com/components/kpi-dashboard.md))

---

Canonical: https://wingo-ui.com/components/filter-builder
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
