ComponentsTables and views

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.

CandidateCompanyStage
Maya ChenNorthwind LogisticsInterview2h ago
Daniel OkaforHalcyon BankShort listYesterday
Priya ShahOrbital FreightLong list3 Oct
States
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.

CandidateCompanyStageUpdatedEmail
Maya ChenNorthwind LogisticsInterview2h agomaya.chen@northwind.co
Daniel OkaforHalcyon BankCV sentYesterdaydaniel@okafor.dev
Priya ShahOrbital FreightOffer3 Octpriya.shah@orbital.io
Tom LindqvistHalcyon BankPlaced28 Septom@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.

CandidateCompanyStageSkillsSalaryAvailableEmail
Maya ChenNorthwind LogisticsInterviewPythonSparkSQLAirflow£95,0001 Novmaya.chen@northwind.co
Daniel OkaforHalcyon BankShort listRiskBasel III£120,00015 Decdaniel@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.

CompanySectorOwner
Northwind LogisticsLogisticsJoseph Ariel
Halcyon BankBankingSara Lee
Orbital FreightLogisticsJoseph 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.

CompanySector
All 3 companies on this page are selected.
Northwind LogisticsLogistics
Halcyon BankBanking
Orbital FreightLogistics
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 riskHalcyon Bank138,000 GBP
Senior data engineerNorthwind Logistics224,000 GBP
Fleet plannerOrbital Freight412,500 GBP
Treasury analystHalcyon 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.

ContactJob titleCompanyPhone
Hannah MossHead of talentNorthwind Logistics+44 20 7946 0018
Kwame AsanteChief risk officerHalcyon 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.

CandidateSummary
Maya ChenWants 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 OkaforNotice 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>
  );
}

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.

CandidateJobUpdated
Maya ChenSenior data engineer1 Oct
Daniel OkaforSenior data engineer2 Oct
Priya ShahSenior data engineer3 Oct
Tom LindqvistSenior data engineer4 Oct
Aisha BelloSenior data engineer5 Oct
Lucas MoreauSenior data engineer6 Oct
Hana SatoSenior data engineer7 Oct
Omar HaddadSenior data engineer8 Oct
Grace KimSenior data engineer9 Oct
Ivan PetrovSenior data engineer10 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.

CandidateCurrent roleStageSalaryChangeApplicationsAvailable
Nguyễn Thị Minh Khai Phạm Hoàng Anh Thư TrầnPrincipal platform engineer, data infrastructure and reliabilityInterview1,234,567,890 GBP−1,2001281 Nov 2026
王小明Quant analystLong list0 GBP00
Zoë O'Brien-SmithShort list3
JQAOutreach151,234,56712 Dec 2026
🦊 Fox Nakamura🚀 Growth leadLong list52,000 GBP1
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.

CandidateFeeDays to fill
Tom Lindqvist£24,00041
Priya Shah£31,50027
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.

CandidateNumber
Candidate 11
Candidate 22
Candidate 33
Candidate 44
Candidate 55
Candidate 66
Candidate 77
Candidate 88
Candidate 99
Candidate 1010
Candidate 1111
Candidate 1212
Candidate 1313
Candidate 1414
Candidate 1515
Candidate 1616
Candidate 1717
Candidate 1818
Candidate 1919
Candidate 2020
Candidate 2121
Candidate 2222
Candidate 2323
Candidate 2424
Candidate 2525
Candidate 2626
Candidate 2727
Candidate 2828
Candidate 2929
Candidate 3030
Candidate 3131
Candidate 3232
Candidate 3333
Candidate 3434
Candidate 3535
Candidate 3636
Candidate 3737
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.

ContactCompanyArchive
Hannah MossNorthwind Logistics
Kwame AsanteHalcyon Bank
Lena FischerOrbital 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.

CandidateUpdated
Maya Chen2h ago
Daniel OkaforYesterday
Priya Shah3 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.

CompanySector

No companies yet

Import 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.

CandidateUpdated

No candidates match this view

Try 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.

CandidateUpdated

This list didn't load

Check 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&apos;t load</EmptyTitle>
            <EmptyDescription>Check your connection, then try again.</EmptyDescription>
          </EmptyHeader>
          <EmptyContent>
            <Button>Try again</Button>
          </EmptyContent>
        </Empty>
      }
    />
  );
}

On this page