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.
| Candidate | Owner |
|---|---|
| Maya Chen | Joseph 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.
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']} />}
/>
);
}Table
The record grid: every list of Companies, Contacts, Jobs and Applications. 36px rows, a 40px header, hairlines both ways. Names in ink, everything else subtle.
View change notice
A quiet line above a table saying what its View lost when a field was archived or deleted. Said once, on the first load that shows it.