Filter builder
Filter opens a searchable field list. A field opens its conditions and a value editor for its type. Each filter then shows as a chip in the toolbar; filters combine with and.
Playground
Change the controls to try the filter chip's props. The code under the preview lists only the props you changed.
import { FilterChip } from '@/ui/filter-chip';<FilterChip field="Stage" value="CV sent, Interview" />Field list, condition and value
Open Filter: search first, then every filterable field with its type icon. Typing narrows the list, and "No fields match." shows when nothing does. A field opens its conditions as radios and a value editor for its type: choices as checkboxes with their lozenge, text as a box that applies as you type, a date, or Yes and No. Back returns to the list.
import { type FilterField, FilterBuilder } from '@/ui/filter-builder';
const fields: FilterField[] = [
{ key: 'stage', label: 'Stage', kind: 'choice', icon: 'status', options: [
{ value: 'cv_sent', label: 'CV sent', appearance: 'information' },
{ value: 'interview', label: 'Interview', appearance: 'information' },
{ value: 'offer', label: 'Offer', appearance: 'discovery' },
{ value: 'placed', label: 'Placed', appearance: 'success' },
] },
{ key: 'owner', label: 'Owner', kind: 'choice', icon: 'person', options: [{ value: 'joseph', label: 'Joseph Ariel' }, { value: 'sara', label: 'Sara Lee' }] },
{ key: 'role', label: 'Current role', kind: 'text', icon: 'text' },
{ key: 'skills', label: 'Skills', kind: 'multi_select', icon: 'tags', options: [{ value: 'python', label: 'Python' }, { value: 'sql', label: 'SQL' }] },
{ key: 'created', label: 'Created', kind: 'date', icon: 'date' },
{ key: 'relocation', label: 'Open to relocation', kind: 'checkbox', icon: 'checkbox' },
];
// Open Filter: search the fields, pick one, choose its condition and value. Each filter becomes a chip.
export default function FilterBuilderDefault() {
return (
<div className="flex flex-wrap items-center gap-100">
<FilterBuilder fields={fields} data-replay="" />
</div>
);
}Applied filters
One chip per filter, in the order they were added, read as a sentence: field and condition subtle, the value in ink. Click a chip to reopen its filter; × removes it. A filtered field shows a check in the list. The Archived tab's filter carries the archive icon.
import { type Filter, type FilterField, FilterBuilder } from '@/ui/filter-builder';
const fields: FilterField[] = [
{ key: 'stage', label: 'Stage', kind: 'choice', icon: 'status', options: [
{ value: 'cv_sent', label: 'CV sent', appearance: 'information' },
{ value: 'interview', label: 'Interview', appearance: 'information' },
{ value: 'placed', label: 'Placed', appearance: 'success' },
] },
{ key: 'role', label: 'Current role', kind: 'text', icon: 'text' },
{ key: 'created', label: 'Created', kind: 'date', icon: 'date' },
{ key: 'skills', label: 'Skills', kind: 'multi_select', icon: 'tags', options: [{ value: 'python', label: 'Python' }] },
{ key: 'archived', label: 'Archived', kind: 'checkbox', icon: 'checkbox' },
];
const filters: Filter[] = [
{ field: 'stage', op: 'is', value: ['cv_sent', 'interview'] },
{ field: 'role', op: 'contains', value: 'data engineer' },
{ field: 'created', op: 'after', value: '2026-09-01' },
{ field: 'skills', op: 'is_empty' },
{ field: 'archived', op: 'is', value: true },
];
// One chip per filter, in the order they were added. Click a chip to reopen its filter; × removes it.
export default function FilterBuilderApplied() {
return (
<div className="flex flex-wrap items-center gap-100">
<FilterBuilder fields={fields} defaultFilters={filters} />
</div>
);
}Filtering a table
Filters change the table at once; there is no Apply button. When they hide every row, the table says how many and offers one way back. Filter Current role to "quant" to see it.
| Candidate | Stage | Current role |
|---|---|---|
| Maya Chen | CV sent | Senior data engineer |
| Priya Shah | CV sent | Data engineer |
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '@/ui/empty';
import { type Filter, type FilterField, FilterBuilder } from '@/ui/filter-builder';
import { Lozenge } from '@/ui/lozenge';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Candidate = { id: string; name: string; stage: 'cv_sent' | 'placed'; role: string };
const candidates: Candidate[] = [
{ id: 'c1', name: 'Maya Chen', stage: 'cv_sent', role: 'Senior data engineer' },
{ id: 'c2', name: 'Daniel Okafor', stage: 'placed', role: 'Head of risk' },
{ id: 'c3', name: 'Priya Shah', stage: 'cv_sent', role: 'Data engineer' },
];
const stageLabel = { cv_sent: 'CV sent', placed: 'Placed' } as const;
const fields: FilterField[] = [
{ key: 'stage', label: 'Stage', kind: 'choice', icon: 'status', options: [{ value: 'cv_sent', label: 'CV sent', appearance: 'information' }, { value: 'placed', label: 'Placed', appearance: 'success' }] },
{ key: 'role', label: 'Current role', kind: 'text', icon: 'text' },
];
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', cell: (row) => <RecordChip type="candidate" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'stage', label: 'Stage', cell: (row) => <Lozenge phase={row.stage === 'placed' ? 'placed' : 'client'}>{stageLabel[row.stage]}</Lozenge> },
{ key: 'role', label: 'Current role', width: 220, cell: (row) => row.role },
];
// The app filters the rows; here the example does. Filter Current role to "quant" to see the no-match state.
export default function FilterBuilderFilteringATable() {
const [filters, setFilters] = useState<Filter[]>([{ field: 'stage', op: 'is', value: ['cv_sent'] }]);
const keep = (row: Candidate) =>
filters.every((filter) => {
if (filter.field === 'stage' && Array.isArray(filter.value)) return filter.op === 'is' ? filter.value.includes(row.stage) : !filter.value.includes(row.stage);
if (filter.field === 'role' && typeof filter.value === 'string') {
const has = row.role.toLowerCase().includes(filter.value.toLowerCase());
return filter.op === 'contains' ? has : !has;
}
return true;
});
const rows = candidates.filter(keep);
return (
<div className="flex w-full flex-col gap-100">
<div className="flex flex-wrap items-center gap-100">
<FilterBuilder fields={fields} filters={filters} onFiltersChange={setFilters} />
</div>
<Table
label="Candidates"
columns={columns}
rows={rows}
empty={
<Empty>
<EmptyHeader>
<EmptyTitle>No candidates match this view</EmptyTitle>
<EmptyDescription>Try a different search, or clear the filters to see every candidate.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
{candidates.length - rows.length} candidates hidden by filters
<Button onClick={() => setFilters([])}>Clear search and filters</Button>
</EmptyContent>
</Empty>
}
/>
</div>
);
}Long values
A long value ends in an ellipsis with the whole value in a tooltip on hover and focus; the field and condition stay whole. Values in any script show as stored.
import { type FilterField, FilterBuilder } from '@/ui/filter-builder';
const fields: FilterField[] = [
{ key: 'sector', label: 'Sector', kind: 'multi_select', icon: 'tags', options: [
{ value: 'fin', label: 'Financial services and insurance' },
{ value: 'log', label: 'Logistics, freight and supply chain' },
{ value: 'vn', label: 'Dịch vụ tài chính' },
{ value: 'cn', label: '金融服务' },
] },
{ key: 'notice', label: 'Notice period agreed with the current employer', kind: 'text', icon: 'text' },
];
// A long value ends in an ellipsis with the whole value in a tooltip; the field and condition stay whole.
export default function FilterBuilderLongValues() {
return (
<div className="flex w-full flex-wrap items-center gap-100">
<FilterBuilder
fields={fields}
defaultFilters={[
{ field: 'sector', op: 'includes', value: ['fin', 'log', 'vn', 'cn'] },
{ field: 'notice', op: 'contains', value: 'three months, negotiable to six weeks' },
]}
/>
</div>
);
}