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.
Playground
Change the controls to try each prop. The preview fills the grid with three candidates; the code under it lists only the props you changed.
| Candidate | Company | Stage | |
|---|---|---|---|
| Maya Chen | Northwind Logistics | Interview | 2h ago |
| Daniel Okafor | Halcyon Bank | Short list | Yesterday |
| Priya Shah | Orbital Freight | Long list | 3 Oct |
import { Table } from '@/ui/table';<Table label="Candidates" />Default
Columns as data, cells as render functions. The first column is the record's name as a link to the record: pinned, ink, with a darker edge. Other columns are subtle unless they hold an email or phone. When the grid is wider than its frame, a soft shadow marks the edge with more columns past it, and the name column casts one once you scroll sideways.
| Candidate | Company | Stage | Updated | |
|---|---|---|---|---|
| Maya Chen | Northwind Logistics | Interview | 2h ago | maya.chen@northwind.co |
| Daniel Okafor | Halcyon Bank | CV sent | Yesterday | daniel@okafor.dev |
| Priya Shah | Orbital Freight | Offer | 3 Oct | priya.shah@orbital.io |
| Tom Lindqvist | Halcyon Bank | Placed | 28 Sep | tom@lindqvist.se |
import { Lozenge } from '@/ui/lozenge';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Candidate = { id: string; name: string; company: string; stage: string; phase: 'working' | 'client' | 'offer' | 'placed'; updated: string; email: string };
const candidates: Candidate[] = [
{ id: 'c1', name: 'Maya Chen', company: 'Northwind Logistics', stage: 'Interview', phase: 'working', updated: '2h ago', email: 'maya.chen@northwind.co' },
{ id: 'c2', name: 'Daniel Okafor', company: 'Halcyon Bank', stage: 'CV sent', phase: 'client', updated: 'Yesterday', email: 'daniel@okafor.dev' },
{ id: 'c3', name: 'Priya Shah', company: 'Orbital Freight', stage: 'Offer', phase: 'offer', updated: '3 Oct', email: 'priya.shah@orbital.io' },
{ id: 'c4', name: 'Tom Lindqvist', company: 'Halcyon Bank', stage: 'Placed', phase: 'placed', updated: '28 Sep', email: 'tom@lindqvist.se' },
];
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', cell: (row) => <RecordChip type="candidate" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'company', label: 'Company', width: 220, cell: (row) => row.company },
{ key: 'stage', label: 'Stage', cell: (row) => <Lozenge phase={row.phase}>{row.stage}</Lozenge> },
{ key: 'updated', label: 'Updated', cell: (row) => row.updated },
{ key: 'email', label: 'Email', width: 240, tone: 'default', cell: (row) => row.email },
];
export default function TableDefault() {
return <Table label="Candidates" columns={columns} rows={candidates} rowLabel={(row) => row.name} />;
}Field type cells
Each column shows its field type icon in the header. Cells hold one line: a record chip, a stage lozenge, a tag group, numbers right-aligned in tabular figures.
| Candidate | Company | Stage | Skills | Salary | Available | ||
|---|---|---|---|---|---|---|---|
| Maya Chen | Northwind Logistics | Interview | PythonSparkSQLAirflow | £95,000 | 1 Nov | maya.chen@northwind.co | |
| Daniel Okafor | Halcyon Bank | Short list | RiskBasel III | £120,000 | 15 Dec | daniel@okafor.dev |
import { CalendarBlankIcon, EnvelopeSimpleIcon, HashIcon, ListBulletsIcon, TagIcon, TextAaIcon } from '@phosphor-icons/react';
import { Lozenge } from '@/ui/lozenge';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
import { Tag } from '@/ui/tag';
import { TagGroup } from '@/ui/tag-group';
type Candidate = { id: string; name: string; company: string; domain?: string; stage: string; skills: string[]; salary: number; available: string; email: string };
const candidates: Candidate[] = [
{ id: 'c1', name: 'Maya Chen', company: 'Northwind Logistics', domain: 'northwind.co', stage: 'Interview', skills: ['Python', 'Spark', 'SQL', 'Airflow'], salary: 95000, available: '1 Nov', email: 'maya.chen@northwind.co' },
{ id: 'c2', name: 'Daniel Okafor', company: 'Halcyon Bank', stage: 'Short list', skills: ['Risk', 'Basel III'], salary: 120000, available: '15 Dec', email: 'daniel@okafor.dev' },
];
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', icon: <TextAaIcon />, cell: (row) => <RecordChip type="candidate" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'company', label: 'Company', icon: <ListBulletsIcon />, width: 220, cell: (row) => <RecordChip type="company" name={row.company} domain={row.domain} href={`#${row.id}-company`} cell /> },
{ key: 'stage', label: 'Stage', icon: <ListBulletsIcon />, cell: (row) => <Lozenge phase="working">{row.stage}</Lozenge> },
{ key: 'skills', label: 'Skills', icon: <TagIcon />, width: 200, cell: (row) => <TagGroup label="Skills" inCell>{row.skills.map((skill) => <Tag key={skill}>{skill}</Tag>)}</TagGroup> },
{ key: 'salary', label: 'Salary', icon: <HashIcon />, width: 120, align: 'end', cell: (row) => `£${row.salary.toLocaleString('en-GB')}` },
{ key: 'available', label: 'Available', icon: <CalendarBlankIcon />, width: 120, cell: (row) => row.available },
{ key: 'email', label: 'Email', icon: <EnvelopeSimpleIcon />, width: 240, tone: 'default', cell: (row) => row.email },
];
export default function TableFieldTypes() {
return <Table label="Candidates" columns={columns} rows={candidates} rowLabel={(row) => row.name} selectable />;
}Selection
selectable adds the checkbox column. A picked row fills with the selected green, pinned cells included. The header box picks every row shown and shows a dash when only some are picked. Shift-click picks the range from the last row clicked; ⌘-click (Ctrl-click) picks one row and leaves that anchor where it was.
| Company | Sector | Owner | |
|---|---|---|---|
| Northwind Logistics | Logistics | Joseph Ariel | |
| Halcyon Bank | Banking | Sara Lee | |
| Orbital Freight | Logistics | Joseph Ariel |
1 of 3 selected
import { useState } from 'react';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Company = { id: string; name: string; domain?: string; sector: string; owner: string };
const companies: Company[] = [
{ id: 'co1', name: 'Northwind Logistics', domain: 'northwind.co', sector: 'Logistics', owner: 'Joseph Ariel' },
{ id: 'co2', name: 'Halcyon Bank', sector: 'Banking', owner: 'Sara Lee' },
{ id: 'co3', name: 'Orbital Freight', domain: 'orbital.io', sector: 'Logistics', owner: 'Joseph Ariel' },
];
const columns: TableColumn<Company>[] = [
{ key: 'name', label: 'Company', cell: (row) => <RecordChip type="company" name={row.name} domain={row.domain} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'sector', label: 'Sector', cell: (row) => row.sector },
{ key: 'owner', label: 'Owner', cell: (row) => row.owner },
];
export default function TableSelection() {
const [selected, setSelected] = useState(['co2']);
return (
<div className="flex w-full flex-col gap-100">
<Table label="Companies" columns={columns} rows={companies} rowLabel={(row) => row.name} selectable selected={selected} onSelectedChange={setSelected} />
<p className="text-body-small leading-body-small text-text-subtle" aria-live="polite">
{selected.length} of {companies.length} selected
</p>
</div>
);
}Select all in the View
When the View holds more records than the page, picking the header box offers the rest: "Select all 1,240 companies in this view". Bulk actions then run on the View's filter. Pass selectAllScope="page" to keep the box to the rows shown, named "Select these 100", with no bar.
| Company | Sector | |
|---|---|---|
| All 3 companies on this page are selected. | ||
| Northwind Logistics | Logistics | |
| Halcyon Bank | Banking | |
| Orbital Freight | Logistics | |
import { useState } from 'react';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Company = { id: string; name: string; sector: string };
const companies: Company[] = [
{ id: 'co1', name: 'Northwind Logistics', sector: 'Logistics' },
{ id: 'co2', name: 'Halcyon Bank', sector: 'Banking' },
{ id: 'co3', name: 'Orbital Freight', sector: 'Logistics' },
];
const columns: TableColumn<Company>[] = [
{ key: 'name', label: 'Company', cell: (row) => <RecordChip type="company" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'sector', label: 'Sector', cell: (row) => row.sector },
];
// The page holds 3 of the View's 1,240 companies. Pick the header box, then the bar offers the whole View.
export default function TableSelectAllInView() {
const [selected, setSelected] = useState(['co1', 'co2', 'co3']);
const [allInView, setAllInView] = useState(false);
return (
<Table
label="Companies"
columns={columns}
rows={companies}
rowLabel={(row) => row.name}
noun={{ one: 'company', many: 'companies' }}
totalRows={1240}
selectable
selected={selected}
onSelectedChange={setSelected}
allInView={allInView}
onAllInViewChange={setAllInView}
/>
);
}Sorting
A sortable column's header is a button. The sorted column turns ink with a 12px arrow and sets aria-sort. The table never reorders rows: sort them in onSortChange, with empty values last in both directions.
| Head of risk | Halcyon Bank | 1 | 38,000 GBP |
| Senior data engineer | Northwind Logistics | 2 | 24,000 GBP |
| Fleet planner | Orbital Freight | 4 | 12,500 GBP |
| Treasury analyst | Halcyon Bank |
import { useState } from 'react';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn, type TableSort } from '@/ui/table';
type Job = { id: string; title: string; company: string; openings: number | null; fee: number | null };
const jobs: Job[] = [
{ id: 'j1', title: 'Senior data engineer', company: 'Northwind Logistics', openings: 2, fee: 24000 },
{ id: 'j2', title: 'Head of risk', company: 'Halcyon Bank', openings: 1, fee: 38000 },
{ id: 'j3', title: 'Fleet planner', company: 'Orbital Freight', openings: 4, fee: 12500 },
{ id: 'j4', title: 'Treasury analyst', company: 'Halcyon Bank', openings: null, fee: null },
];
const columns: TableColumn<Job>[] = [
{ key: 'title', label: 'Job', sortable: true, cell: (row) => <RecordChip type="job" name={row.title} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'company', label: 'Company', width: 200, sortable: true, cell: (row) => row.company },
{ key: 'openings', label: 'Openings', type: 'number', width: 120, sortable: true, cell: (row) => row.openings },
{ key: 'fee', label: 'Fee', type: 'money', width: 130, sortable: true, cell: (row) => (row.fee === null ? null : `${row.fee.toLocaleString('en-GB')} GBP`) },
];
// Empty values go last in both directions, as the app's queries sort them.
function compare(a: Job, b: Job, { key, direction }: TableSort) {
const x = a[key as keyof Job];
const y = b[key as keyof Job];
if (x === null || y === null) return x === y ? 0 : x === null ? 1 : -1;
const order = typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y));
return direction === 'asc' ? order : -order;
}
export default function TableSorting() {
const [sort, setSort] = useState<TableSort | null>({ key: 'fee', direction: 'desc' });
// The table never reorders rows: sort them where the data lives, here in the example.
const rows = sort ? [...jobs].sort((a, b) => compare(a, b, sort)) : jobs;
return <Table label="Jobs" columns={columns} rows={rows} sort={sort} onSortChange={setSort} />;
}Column resize
resizable adds a handle on each header's end edge. Drag it to resize between 60 and 1200px, or press ⌥← and ⌥→ on a header for 10px steps. Double-click fits the column to its widest value shown, up to 600px; resizeDoubleClick="reset" returns it to its own width instead. Escape cancels a drag. A brand guide line shows the new edge while you drag.
| Contact | Job title | Company | Phone |
|---|---|---|---|
| Hannah Moss | Head of talent | Northwind Logistics | +44 20 7946 0018 |
| Kwame Asante | Chief risk officer | Halcyon Bank | +44 20 7946 0342 |
import { useState } from 'react';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Contact = { id: string; name: string; title: string; company: string; phone: string };
const contacts: Contact[] = [
{ id: 'p1', name: 'Hannah Moss', title: 'Head of talent', company: 'Northwind Logistics', phone: '+44 20 7946 0018' },
{ id: 'p2', name: 'Kwame Asante', title: 'Chief risk officer', company: 'Halcyon Bank', phone: '+44 20 7946 0342' },
];
const columns: TableColumn<Contact>[] = [
{ key: 'name', label: 'Contact', cell: (row) => <RecordChip type="contact" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'title', label: 'Job title', cell: (row) => row.title },
{ key: 'company', label: 'Company', cell: (row) => row.company },
{ key: 'phone', label: 'Phone', tone: 'default', cell: (row) => row.phone },
];
export default function TableResize() {
const [widths, setWidths] = useState<Record<string, number>>({ title: 200 });
return <Table label="Contacts" columns={columns} rows={contacts} resizable columnWidths={widths} onColumnWidthsChange={setWidths} />;
}Wrap text
wrap lets text cells run to three lines and rows grow to fit, a per-View choice. Off, every row is 36px and long text ends in an ellipsis.
| Candidate | Summary |
|---|---|
| Maya Chen | Wants a hybrid role in London. Open to fintech after the Halcyon interview; prefers a team that owns its data platform end to end, with on-call shared fairly. |
| Daniel Okafor | Notice period three months. |
import { useState } from 'react';
import { Button } from '@/ui/button';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Note = { id: string; name: string; summary: string };
const notes: Note[] = [
{ id: 'c1', name: 'Maya Chen', summary: 'Wants a hybrid role in London. Open to fintech after the Halcyon interview; prefers a team that owns its data platform end to end, with on-call shared fairly.' },
{ id: 'c2', name: 'Daniel Okafor', summary: 'Notice period three months.' },
];
const columns: TableColumn<Note>[] = [
{ key: 'name', label: 'Candidate', width: 180, cell: (row) => <RecordChip type="candidate" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'summary', label: 'Summary', width: 320, cell: (row) => row.summary },
];
export default function TableWrap() {
const [wrap, setWrap] = useState(true);
return (
<div className="flex w-full flex-col items-start gap-150">
<Button selected={wrap} onClick={() => setWrap(!wrap)}>
Wrap text
</Button>
<Table label="Candidates" columns={columns} rows={notes} rowLabel={(row) => row.name} wrap={wrap} />
</div>
);
}Sticky header
Give the table a height with className; the header stays as the rows scroll under it, and the name column stays as the grid scrolls sideways.
| Candidate | Job | Updated |
|---|---|---|
| Maya Chen | Senior data engineer | 1 Oct |
| Daniel Okafor | Senior data engineer | 2 Oct |
| Priya Shah | Senior data engineer | 3 Oct |
| Tom Lindqvist | Senior data engineer | 4 Oct |
| Aisha Bello | Senior data engineer | 5 Oct |
| Lucas Moreau | Senior data engineer | 6 Oct |
| Hana Sato | Senior data engineer | 7 Oct |
| Omar Haddad | Senior data engineer | 8 Oct |
| Grace Kim | Senior data engineer | 9 Oct |
| Ivan Petrov | Senior data engineer | 10 Oct |
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Application = { id: string; candidate: string; job: string; updated: string };
const names = ['Maya Chen', 'Daniel Okafor', 'Priya Shah', 'Tom Lindqvist', 'Aisha Bello', 'Lucas Moreau', 'Hana Sato', 'Omar Haddad', 'Grace Kim', 'Ivan Petrov'];
const applications: Application[] = names.map((candidate, index) => ({ id: `a${index}`, candidate, job: 'Senior data engineer', updated: `${index + 1} Oct` }));
const columns: TableColumn<Application>[] = [
{ key: 'candidate', label: 'Candidate', cell: (row) => <RecordChip type="candidate" name={row.candidate} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'job', label: 'Job', width: 220, cell: (row) => row.job },
{ key: 'updated', label: 'Updated', cell: (row) => row.updated },
];
export default function TableStickyHeader() {
return <Table label="Applications" columns={columns} rows={applications} className="max-h-60" />;
}Long, empty and odd values
Every value that breaks a naive table, in one place. Text that runs out of room ends in an ellipsis; hover it, or press Enter on its cell, for the whole value in a tooltip. Names in any script and emoji show as stored. A missing value is a blank cell, while 0 is a value and shows. Numbers, amounts and dates never clip: their column widens to fit the widest one. A value that can't be read, such as NaN or an invalid date, never prints.
| Candidate | Current role | Stage | Salary | Change | Applications | Available |
|---|---|---|---|---|---|---|
| Nguyễn Thị Minh Khai Phạm Hoàng Anh Thư Trần | Principal platform engineer, data infrastructure and reliability | Interview | 1,234,567,890 GBP | −1,200 | 128 | 1 Nov 2026 |
| 王小明 | Quant analyst | Long list | 0 GBP | 0 | 0 | |
| Zoë O'Brien-Smith | Short list | 3 | ||||
| J | QA | Outreach | 15 | 1,234,567 | 12 Dec 2026 | |
| 🦊 Fox Nakamura | 🚀 Growth lead | Long list | 52,000 GBP | 1 |
import { Lozenge } from '@/ui/lozenge';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Candidate = { id: string; name: string; role: string | null; stage: string; salary: number | null; change: number | null; applications: number; available: string | null };
// Every value that breaks naive tables: long, one letter, empty, huge, negative, zero next to empty, non-Latin, emoji.
const candidates: Candidate[] = [
{ id: 'c1', name: 'Nguyễn Thị Minh Khai Phạm Hoàng Anh Thư Trần', role: 'Principal platform engineer, data infrastructure and reliability', stage: 'Interview', salary: 1234567890, change: -1200, applications: 128, available: '1 Nov 2026' },
{ id: 'c2', name: '王小明', role: 'Quant analyst', stage: 'Long list', salary: 0, change: 0, applications: 0, available: null },
{ id: 'c3', name: "Zoë O'Brien-Smith", role: null, stage: 'Short list', salary: null, change: null, applications: 3, available: 'Invalid Date' },
{ id: 'c4', name: 'J', role: 'QA', stage: 'Outreach', salary: Number.NaN, change: 15, applications: 1234567, available: '12 Dec 2026' },
{ id: 'c5', name: '🦊 Fox Nakamura', role: '🚀 Growth lead', stage: 'Long list', salary: 52000, change: null, applications: 1, available: null },
];
const money = (value: number | null) => (value === null || Number.isNaN(value) ? null : `${value.toLocaleString('en-GB')} GBP`);
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', width: 200, cell: (row) => <RecordChip type="candidate" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'role', label: 'Current role', width: 180, cell: (row) => row.role },
{ key: 'stage', label: 'Stage', width: 120, cell: (row) => <Lozenge phase="working">{row.stage}</Lozenge> },
{ key: 'salary', label: 'Salary', type: 'money', width: 120, cell: (row) => money(row.salary) },
{ key: 'change', label: 'Change', type: 'number', width: 100, cell: (row) => (row.change === null ? null : row.change.toLocaleString('en-GB').replace('-', '−')) },
{ key: 'applications', label: 'Applications', type: 'number', width: 130, cell: (row) => row.applications.toLocaleString('en-GB') },
{ key: 'available', label: 'Available', type: 'date', width: 110, cell: (row) => row.available },
];
export default function TableContentExtremes() {
return <Table label="Candidates" columns={columns} rows={candidates} rowLabel={(row) => row.name} />;
}Read-only column
locked puts a lock after the label with the reason in a tooltip, for a calculated column or one this person's role can't edit. Say why and who can change it. hint does the same with an info icon, for a column whose label needs a line of help.
| Candidate | Fee | Days to fill |
|---|---|---|
| Tom Lindqvist | £24,000 | 41 |
| Priya Shah | £31,500 | 27 |
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Placement = { id: string; candidate: string; fee: string; daysToFill: number };
const placements: Placement[] = [
{ id: 'p1', candidate: 'Tom Lindqvist', fee: '£24,000', daysToFill: 41 },
{ id: 'p2', candidate: 'Priya Shah', fee: '£31,500', daysToFill: 27 },
];
const columns: TableColumn<Placement>[] = [
{ key: 'candidate', label: 'Candidate', cell: (row) => <RecordChip type="candidate" name={row.candidate} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'fee', label: 'Fee', width: 140, align: 'end', locked: 'Only Owners and Admins can edit fees. Ask Joseph.', cell: (row) => row.fee },
{ key: 'days', label: 'Days to fill', width: 160, align: 'end', locked: 'Calculated from the job and placement dates.', cell: (row) => row.daysToFill },
];
export default function TableReadOnlyColumn() {
return <Table label="Placements" columns={columns} rows={placements} rowLabel={(row) => row.candidate} />;
}First load
TableSkeleton holds the grid's real geometry while the first page loads: the 40px header and 36px rows. It stays hidden for 300ms, so a fast load never flashes.
import { TableSkeleton } from '@/ui/table';
export default function TableFirstLoad() {
return <TableSkeleton label="Loading candidates" rows={5} />;
}Long lists
Past 500 rows only the rows in view are drawn, so a list of 10,000 scrolls as smoothly as one of 10. The header stays put and the arrow keys walk the whole list.
| Candidate | Number |
|---|---|
| Candidate 1 | 1 |
| Candidate 2 | 2 |
| Candidate 3 | 3 |
| Candidate 4 | 4 |
| Candidate 5 | 5 |
| Candidate 6 | 6 |
| Candidate 7 | 7 |
| Candidate 8 | 8 |
| Candidate 9 | 9 |
| Candidate 10 | 10 |
| Candidate 11 | 11 |
| Candidate 12 | 12 |
| Candidate 13 | 13 |
| Candidate 14 | 14 |
| Candidate 15 | 15 |
| Candidate 16 | 16 |
| Candidate 17 | 17 |
| Candidate 18 | 18 |
| Candidate 19 | 19 |
| Candidate 20 | 20 |
| Candidate 21 | 21 |
| Candidate 22 | 22 |
| Candidate 23 | 23 |
| Candidate 24 | 24 |
| Candidate 25 | 25 |
| Candidate 26 | 26 |
| Candidate 27 | 27 |
| Candidate 28 | 28 |
| Candidate 29 | 29 |
| Candidate 30 | 30 |
| Candidate 31 | 31 |
| Candidate 32 | 32 |
| Candidate 33 | 33 |
| Candidate 34 | 34 |
| Candidate 35 | 35 |
| Candidate 36 | 36 |
| Candidate 37 | 37 |
import { Table, type TableColumn } from '@/ui/table';
type Candidate = { id: string; name: string; number: number };
// 10,000 rows: only the ones in view are drawn, so scrolling stays smooth.
const candidates: Candidate[] = Array.from({ length: 10000 }, (_, index) => ({ id: `c${index + 1}`, name: `Candidate ${(index + 1).toLocaleString('en-GB')}`, number: index + 1 }));
const columns: TableColumn<Candidate>[] = [
// Plain names keep this example light; a record grid links each name with RecordChip href.
{ key: 'name', label: 'Candidate', cell: (row) => row.name },
{ key: 'number', label: 'Number', type: 'number', width: 120, cell: (row) => row.number.toLocaleString('en-GB') },
];
export default function TableLongList() {
return <Table label="Candidates" columns={columns} rows={candidates} className="max-h-80" />;
}Focus after a row leaves
When the row with the focused cell is archived or deleted, focus moves to the same column of the next row, or the previous one at the end. It never drops to the page.
| Contact | Company | Archive |
|---|---|---|
| Hannah Moss | Northwind Logistics | |
| Kwame Asante | Halcyon Bank | |
| Lena Fischer | Orbital Freight |
import { ArchiveIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { IconButton } from '@/ui/button';
import { moveList } from '@/ui/motion';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Contact = { id: string; name: string; company: string };
const start: Contact[] = [
{ id: 'p1', name: 'Hannah Moss', company: 'Northwind Logistics' },
{ id: 'p2', name: 'Kwame Asante', company: 'Halcyon Bank' },
{ id: 'p3', name: 'Lena Fischer', company: 'Orbital Freight' },
];
export default function TableFocusAfterArchive() {
const [contacts, setContacts] = useState(start);
const columns: TableColumn<Contact>[] = [
{ key: 'name', label: 'Contact', cell: (row) => <RecordChip type="contact" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'company', label: 'Company', width: 200, cell: (row) => row.company },
{
key: 'archive',
label: 'Archive',
width: 90,
cell: (row) => (
<IconButton label={`Archive ${row.name}`} size="sm" tabIndex={-1} onClick={() => moveList(() => setContacts((all) => all.filter((each) => each.id !== row.id)))}>
<ArchiveIcon />
</IconButton>
),
},
];
return (
<div className="flex w-full flex-col items-start gap-150">
<Table label="Contacts" columns={columns} rows={contacts} rowLabel={(row) => row.name} rowMotion />
<button type="button" className="text-body leading-body text-brand-bold underline underline-offset-4" onClick={() => moveList(() => setContacts(start))}>
Bring them back
</button>
</div>
);
}Loading a View
The old rows stay and dim to 60% after a short wait, and the grid sets aria-busy. A first load shows Skeleton instead.
| Candidate | Updated |
|---|---|
| Maya Chen | 2h ago |
| Daniel Okafor | Yesterday |
| Priya Shah | 3 Oct |
import { useState } from 'react';
import { Button } from '@/ui/button';
import { RecordChip } from '@/ui/record-chip';
import { Table, type TableColumn } from '@/ui/table';
type Candidate = { id: string; name: string; updated: string };
const candidates: Candidate[] = [
{ id: 'c1', name: 'Maya Chen', updated: '2h ago' },
{ id: 'c2', name: 'Daniel Okafor', updated: 'Yesterday' },
{ id: 'c3', name: 'Priya Shah', updated: '3 Oct' },
];
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', cell: (row) => <RecordChip type="candidate" name={row.name} variant="plain" href={`#${row.id}`} cell /> },
{ key: 'updated', label: 'Updated', cell: (row) => row.updated },
];
export default function TableLoading() {
const [loading, setLoading] = useState(true);
return (
<div className="flex w-full flex-col items-start gap-150">
<Button selected={loading} onClick={() => setLoading(!loading)}>
{loading ? 'Loading a View' : 'Loaded'}
</Button>
<Table label="Candidates" columns={columns} rows={candidates} loading={loading} />
</div>
);
}First run
Nothing exists yet: an Empty state with the record's icon and the way to add some.
| Company | Sector |
|---|---|
No companies yetImport a spreadsheet or add one by hand. | |
import { BuildingsIcon, PlusIcon, UploadSimpleIcon } from '@phosphor-icons/react';
import { Button } from '@/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/ui/empty';
import { Table, type TableColumn } from '@/ui/table';
type Company = { id: string; name: string; sector: string };
const columns: TableColumn<Company>[] = [
{ key: 'name', label: 'Company', cell: (row) => row.name },
{ key: 'sector', label: 'Sector', cell: (row) => row.sector },
];
export default function TableEmpty() {
return (
<Table
label="Companies"
columns={columns}
rows={[]}
empty={
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<BuildingsIcon />
</EmptyMedia>
<EmptyTitle>No companies yet</EmptyTitle>
<EmptyDescription>Import a spreadsheet or add one by hand.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="primary">
<UploadSimpleIcon />
Import
</Button>
<Button variant="ghost">
<PlusIcon />
Add company
</Button>
</EmptyContent>
</Empty>
}
/>
);
}No match
Filters hide every row. Say how many and offer one way back.
| Candidate | Updated |
|---|---|
No candidates match this viewTry a different search, or clear the filters to see every candidate. 12 candidates hidden by filters | |
import { Button } from '@/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '@/ui/empty';
import { Table, type TableColumn } from '@/ui/table';
type Candidate = { id: string; name: string; updated: string };
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', cell: (row) => row.name },
{ key: 'updated', label: 'Updated', cell: (row) => row.updated },
];
export default function TableNoMatch() {
return (
<Table
label="Candidates"
columns={columns}
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>
12 candidates hidden by filters
<Button>Clear search and filters</Button>
</EmptyContent>
</Empty>
}
/>
);
}Error
The request failed. The header stays; the body offers Try again.
| Candidate | Updated |
|---|---|
This list didn't loadCheck your connection, then try again. | |
import { WarningCircleIcon } from '@phosphor-icons/react';
import { Button } from '@/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/ui/empty';
import { Table, type TableColumn } from '@/ui/table';
type Candidate = { id: string; name: string; updated: string };
const columns: TableColumn<Candidate>[] = [
{ key: 'name', label: 'Candidate', cell: (row) => row.name },
{ key: 'updated', label: 'Updated', cell: (row) => row.updated },
];
export default function TableError() {
return (
<Table
label="Candidates"
columns={columns}
rows={[]}
error={
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon" tone="danger">
<WarningCircleIcon />
</EmptyMedia>
<EmptyTitle>This list didn't load</EmptyTitle>
<EmptyDescription>Check your connection, then try again.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>Try again</Button>
</EmptyContent>
</Empty>
}
/>
);
}