Filter Builder React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
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.
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.
Filter Builder is a Pro React component in the React input components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add filter-builder, together with Badge, Button, Calendar, Currency Input, Date Picker and 22 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add filter-builderFilter Builder is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import FilterBuilder from @/components/ui/filter-builder.
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
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.
variantdefault"chips""chips" | "panel"chips: inline chips, each with its own editor popover. panel: one Filters button with a count that opens a rows editor (Airtable / Notion style).
sizedefault"md""sm" | "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).
radiusdefault"full""none" | "sm" | "md" | "lg" | "full"The chips' and bar buttons' corners, from the button's radius map; full makes pills.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"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.
maxChipsdefault0numberDesktop: chips past this number collapse into a +3 chip that opens the panel. Phones always scroll through all of them.
responsivedefaulttruebooleanBelow 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.
defaultOpendefaultfalsebooleanStarts with the panel (desktop) or the sheet (phones) open.
disableddefaultfalsebooleanDims the builder; nothing opens and chips cannot be removed.
loadingdefaultfalsebooleanA request is running (server-side filtering): the count shows a spinner and dims until it settles.
applyModedefault"instant""instant" | "apply"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.
allowOrdefaulttruebooleanThe root can switch between and / or (the toggle between the first two chips); off keeps every filter AND.
allowGroupsdefaulttruebooleanAdd group in the panel and the sheet: one level of nesting, like (Balance ≥ $5,000 or Last invoice in the last 7 days).
shortcutdefault""stringA 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.
localedefault""stringFormats money, numbers and dates in the chips and editors; the UIProvider's or the browser's when empty. The demo uses en-US.
valueFilterGroupThe filter, controlled: { id, combinator: and | or, conditions: (FilterCondition | FilterGroup)[] }. Pair it with onValueChange.
defaultValueFilterGroupThe starting filter while uncontrolled; an empty AND group when left out.
onValueChange(filter: FilterGroup) => voidEvery applied change. Filter rows with applyFilter from lib/filters, or send serializeFilter(filter) to the server.
viewsFilterView[]Saved views in the views menu, pinned first. Built-in views are readOnly (no Rename or Delete).
activeViewIdstring | nullThe active view, controlled; picking a view replaces the filter, editing it afterwards shows (modified).
defaultActiveViewIddefault""string | nullThe 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) => voidA 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.
resultCountdefault0numberHow 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).
openbooleanThe panel (desktop) or the sheet (phones), controlled.
onOpenChange(open: boolean) => voidThe panel or the sheet opened or closed.
nowDate | numberToday for the quick date ranges (Last 7 days) and the calendars' first month. The demo pins 24.09.2026.
labelsPartial<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).
classNamesPartial<Record<"trigger" | "views" | "chips" | "chip" | "combinator" | "more" | "add" | "clear" | "count" | "popover" | "fieldList" | "panel" | "row" | "group" | "sheet", string>>Extra classes for the inner parts.
classNamestringExtra classes for the root, merged last. Other props (id, aria-*, data-*, handlers) spread onto it.
Parts
FilterChip
componentOne 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
componentThe 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
constantThe English labels; spread them to translate a few. Type: FilterBuilderLabels.
FilterBuilderField
typeA 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.
Dependencies
Files
- components/ui/filter-builder.tsx
FAQ
Is the React Filter Builder component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Filter Builder?
Run npx wingo-ui@latest add filter-builder. It also adds the Wingo UI items it builds on (Badge, Button, Calendar, Currency Input, Date Picker and 22 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/filter-builder.
How does Filter Builder behave on phones?
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. The responsive prop controls this and is on by default.
See also
Used in
More React input components
Articles using this component
- React Filter Builder: AND / OR Chips and Saved Views
Component guides11 min read