ComponentsSelection

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.

CandidateStageCurrent role
Maya ChenCV sentSenior data engineer
Priya ShahCV sentData 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>
  );
}

On this page