ComponentsTables and views

Table toolbar

The 44px bar above every grid. Filter and its chips on the left; search, the save controls and Display on the right.

Playground

Change the controls to try each prop. The code under the preview lists only the props you changed.

import { TableToolbar } from '@/ui/table-toolbar';<TableToolbar defaultSearch="" />

At rest

Every record table opens with this bar. Filter and Display are quiet buttons; search is the only bordered control, and its placeholder names the records.

import { DisplayMenu } from '@/ui/display-menu';
import { FilterBuilder } from '@/ui/filter-builder';
import { TableToolbar } from '@/ui/table-toolbar';

// Every record table opens with this bar. Filter and Display are quiet buttons; search is the only bordered control.
export default function TableToolbarRest() {
  return (
    <TableToolbar
      className="w-full"
      noun={{ one: 'candidate', many: 'candidates' }}
      filter={<FilterBuilder fields={[{ key: 'stage', label: 'Stage', kind: 'choice', icon: 'status', options: [{ value: 'cv_sent', label: 'CV sent', appearance: 'information' }] }]} data-replay="" />}
      display={<DisplayMenu fields={[{ key: 'name', label: 'Candidate', kind: 'text', pinned: true }]} defaultShown={['name']} />}
    />
  );
}

Filtered and searched

Each filter becomes a chip after Filter. Search filters the table as you type.

CandidateOwner
Maya ChenJoseph Ariel
import { useState } from 'react';
import { DisplayMenu } from '@/ui/display-menu';
import { type Filter, type FilterField, FilterBuilder } from '@/ui/filter-builder';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
import { TableToolbar } from '@/ui/table-toolbar';

type Candidate = { id: string; name: string; owner: string };

const candidates: Candidate[] = [
  { id: 'c1', name: 'Maya Chen', owner: 'Joseph Ariel' },
  { id: 'c2', name: 'Daniel Okafor', owner: 'Joseph Ariel' },
  { id: 'c3', name: 'Priya Shah', owner: 'Sara Lee' },
];

const fields: FilterField[] = [{ key: 'owner', label: 'Owner', kind: 'choice', icon: 'person', options: [{ value: 'Joseph Ariel', label: 'Joseph A.' }, { value: 'Sara Lee', label: 'Sara L.' }] }];

const columns: TableColumn<Candidate>[] = [
  { key: 'name', label: 'Candidate', cell: (row) => <RecordChip type="candidate" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
  { key: 'owner', label: 'Owner', cell: (row) => row.owner },
];

// Each filter becomes a chip after Filter. Search filters as you type.
export default function TableToolbarFilteredAndSearched() {
  const [filters, setFilters] = useState<Filter[]>([{ field: 'owner', op: 'is', value: ['Joseph Ariel'] }]);
  const [search, setSearch] = useState('maya');
  const rows = candidates.filter(
    (row) =>
      row.name.toLowerCase().includes(search.trim().toLowerCase()) &&
      filters.every((filter) => !Array.isArray(filter.value) || filter.value.includes(row.owner) === (filter.op === 'is')),
  );
  return (
    <div className="flex w-full flex-col">
      <TableToolbar
        noun={{ one: 'candidate', many: 'candidates' }}
        filter={<FilterBuilder fields={fields} filters={filters} onFiltersChange={setFilters} />}
        search={search}
        onSearchChange={setSearch}
        display={<DisplayMenu fields={[{ key: 'name', label: 'Candidate', kind: 'text', pinned: true }, { key: 'owner', label: 'Owner', kind: 'person' }]} defaultShown={['name', 'owner']} />}
      />
      <Table label="Candidates" columns={columns} rows={rows} />
    </div>
  );
}

Loading and unsaved changes

While the grid reloads, a status line sits left of search. Once the View differs from its saved version, Discard changes and Save for everyone appear before Display.

Updating records…
import { DisplayMenu } from '@/ui/display-menu';
import { FilterBuilder } from '@/ui/filter-builder';
import { SaveViewBar } from '@/ui/save-view-bar';
import { TableToolbar } from '@/ui/table-toolbar';

// While the grid reloads, the status line sits left of search. Once the View differs from its saved version, Discard
// changes and Save for everyone appear before Display.
export default function TableToolbarLoadingAndUnsaved() {
  return (
    <TableToolbar
      className="w-full"
      noun={{ one: 'candidate', many: 'candidates' }}
      status="updating"
      filter={
        <FilterBuilder
          fields={[{ key: 'stage', label: 'Stage', kind: 'choice', icon: 'status', options: [{ value: 'placed', label: 'Placed', appearance: 'success' }] }]}
          defaultFilters={[{ field: 'stage', op: 'is', value: ['placed'] }]}
        />
      }
      save={<SaveViewBar onDiscard={() => {}} onSave={() => {}} />}
      display={<DisplayMenu fields={[{ key: 'name', label: 'Candidate', kind: 'text', pinned: true }]} defaultShown={['name']} />}
    />
  );
}

Rows selected

While rows are selected the bulk action bar covers the toolbar in its place and height, and the toolbar under it is inert.

import { ArchiveIcon } from '@phosphor-icons/react';
import { BulkAction, BulkActionBar } from '@/ui/bulk-action-bar';
import { DisplayMenu } from '@/ui/display-menu';
import { FilterBuilder } from '@/ui/filter-builder';
import { TableToolbar } from '@/ui/table-toolbar';

// While rows are selected the bulk action bar covers the toolbar, and the toolbar under it is inert.
export default function TableToolbarWithSelection() {
  return (
    <TableToolbar
      className="w-full"
      noun={{ one: 'candidate', many: 'candidates' }}
      filter={<FilterBuilder fields={[{ key: 'stage', label: 'Stage', kind: 'choice', icon: 'status' }]} />}
      display={<DisplayMenu fields={[{ key: 'name', label: 'Candidate', kind: 'text', pinned: true }]} defaultShown={['name']} />}
      selection={
        <BulkActionBar count={2} onClear={() => {}}>
          <BulkAction icon={<ArchiveIcon />}>Archive</BulkAction>
        </BulkActionBar>
      }
    />
  );
}

Many filters

More filters than fit: the bar scrolls sideways. It never wraps onto a second line, so the grid never moves.

import { DisplayMenu } from '@/ui/display-menu';
import { type FilterField, FilterBuilder } from '@/ui/filter-builder';
import { TableToolbar } from '@/ui/table-toolbar';

const fields: FilterField[] = [
  { key: 'stage', label: 'Stage', kind: 'choice', icon: 'status', options: [{ value: 'cv_sent', label: 'CV sent' }, { value: 'interview', label: 'Interview' }] },
  { key: 'owner', label: 'Owner', kind: 'choice', icon: 'person', options: [{ value: 'joseph', label: 'Joseph A.' }] },
  { key: 'role', label: 'Current role', kind: 'text', icon: 'text' },
  { key: 'created', label: 'Created', kind: 'date', icon: 'date' },
];

// More filters than fit: the bar scrolls sideways. It never wraps onto a second line.
export default function TableToolbarManyFilters() {
  return (
    <TableToolbar
      className="w-full max-w-160"
      noun={{ one: 'candidate', many: 'candidates' }}
      filter={
        <FilterBuilder
          fields={fields}
          defaultFilters={[
            { field: 'stage', op: 'is', value: ['cv_sent', 'interview'] },
            { field: 'owner', op: 'is', value: ['joseph'] },
            { field: 'role', op: 'contains', value: 'data engineer' },
            { field: 'created', op: 'after', value: '2026-09-01' },
          ]}
        />
      }
      display={<DisplayMenu fields={[{ key: 'name', label: 'Candidate', kind: 'text', pinned: true }]} defaultShown={['name']} />}
    />
  );
}

On this page