Table
Hairline rows in tabular figures; the column you sort by is set in ink, or set loud; or the row and column you point at cross in ink.
Report an issue with TableExample
Ink
| Northlight | Web | 2026 | £62,000 | |
| Tidewater | Motion | 2025 | £54,000 | |
| Halden | Identity | 2026 | £48,000 | |
| Marram | Web | 2025 | £41,000 | |
| Oda Studio | Identity | 2025 | £36,000 | |
| Felt & Field | Identity | 2024 | £33,000 | |
| Quiet Hours | Web | 2024 | £29,000 |
0 of 7 chosen
Forte
| Northlight | Web | 2026 | £62,000 | |
| Tidewater | Motion | 2025 | £54,000 | |
| Halden | Identity | 2026 | £48,000 | |
| Marram | Web | 2025 | £41,000 | |
| Oda Studio | Identity | 2025 | £36,000 | |
| Felt & Field | Identity | 2024 | £33,000 | |
| Quiet Hours | Web | 2024 | £29,000 |
0 of 7 chosen
Cross
| From | Oslo | Basel | Lisbon | Lagos | Kyoto |
|---|---|---|---|---|---|
| Oslo | 1,370 | 2,740 | 5,980 | 8,570 | |
| Basel | 1,370 | 1,560 | 4,520 | 9,600 | |
| Lisbon | 2,740 | 1,560 | 3,800 | 11,000 | |
| Lagos | 5,980 | 4,520 | 3,800 | 13,300 | |
| Kyoto | 8,570 | 9,600 | 11,000 | 13,300 |
Forte, sorted by fee
| Project | Fee |
|---|---|
| Northlight | £62,000 |
| Tidewater | £54,000 |
| Halden | £48,000 |
Cross, pointed at Lisbon to Kyoto
| From | Oslo | Basel | Lisbon | Lagos | Kyoto |
|---|---|---|---|---|---|
| Oslo | 1,370 | 2,740 | 5,980 | 8,570 | |
| Basel | 1,370 | 1,560 | 4,520 | 9,600 | |
| Lisbon | 2,740 | 1,560 | 3,800 | 11,000 | |
| Lagos | 5,980 | 4,520 | 3,800 | 13,300 | |
| Kyoto | 8,570 | 9,600 | 11,000 | 13,300 |
Install
Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base along.
npx shadcn@latest add https:// thedirectors.agency/ ui/ r/table.jsonUsage
"use client"import * as React from "react"import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow, TablePick, type TableProps } from "@/components/ui/table"const projects = [{ name: "Northlight", kind: "Web", year: 2026, fee: 62000 },{ name: "Tidewater", kind: "Motion", year: 2025, fee: 54000 },{ name: "Halden", kind: "Identity", year: 2026, fee: 48000 },{ name: "Marram", kind: "Web", year: 2025, fee: 41000 },{ name: "Oda Studio", kind: "Identity", year: 2025, fee: 36000 },{ name: "Felt & Field", kind: "Identity", year: 2024, fee: 33000 },{ name: "Quiet Hours", kind: "Web", year: 2024, fee: 29000 },]type Key = keyof (typeof projects)[number]const columns: { key: Key; label: string; numeric?: boolean }[] = [{ key: "name", label: "Project" },{ key: "kind", label: "Kind" },{ key: "year", label: "Year", numeric: true },{ key: "fee", label: "Fee", numeric: true },]const pounds = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP", maximumFractionDigits: 0 })const cities = ["Oslo", "Basel", "Lisbon", "Lagos", "Kyoto"]// Kilometres as the crow flies, rounded; the chart is symmetric, so each pair is listed once.const km: Record<string, number> = {"Oslo Basel": 1370, "Oslo Lisbon": 2740, "Oslo Lagos": 5980, "Oslo Kyoto": 8570, "Basel Lisbon": 1560,"Basel Lagos": 4520, "Basel Kyoto": 9600, "Lisbon Lagos": 3800, "Lisbon Kyoto": 11000, "Lagos Kyoto": 13300,}const between = (a: string, b: string) => km[`${a} ${b}`] ?? km[`${b} ${a}`]const figures = new Intl.NumberFormat("en-GB")function Distances({ force }: { force?: [number, number] }) {return (<Table variant="cross"><TableCaption>Distances between the studios, in kilometres</TableCaption><TableHeader><TableRow><TableHead><span className="ot-sr">From</span></TableHead>{cities.map((c) => <TableHead key={c} numeric>{c}</TableHead>)}</TableRow></TableHeader><TableBody>{cities.map((from, r) => (<TableRow key={from}><TableHead scope="row">{from}</TableHead>{cities.map((to, c) => (<TableCell key={to} numeric data-force={force?.[0] === r && force[1] === c ? "hover" : undefined}>{from === to ? "" : figures.format(between(from, to))}</TableCell>))}</TableRow>))}</TableBody></Table>)}export default function Example() {return (<div className="grid w-full grid-cols-[minmax(0,1fr)] justify-items-start gap-12"><div className="grid w-full justify-items-start gap-3"><span className="ot-label">Ink</span><Projects /></div><div className="grid w-full justify-items-start gap-3"><span className="ot-label">Forte</span><Projects variant="forte" /></div><div className="grid w-full justify-items-start gap-3"><span className="ot-label">Cross</span><Distances /></div></div>)}function Projects({ variant }: { variant?: TableProps["variant"] }) {const [sort, setSort] = React.useState<{ key: Key; dir: 1 | -1 }>({ key: "fee", dir: -1 })const [picked, setPicked] = React.useState<Set<string>>(new Set())const rows = [...projects].sort((a, b) => (a[sort.key] > b[sort.key] ? 1 : -1) * sort.dir)const all = picked.size === projects.lengthreturn (<div className="grid w-full gap-(--ot-space-4)"><Table variant={variant}><TableCaption>Fees by project</TableCaption><TableHeader><TableRow><TableHead><TablePickaria-label="Choose all projects"checked={all}ref={(el) => { if (el) el.indeterminate = picked.size > 0 && !all }}onChange={() => setPicked(all ? new Set() : new Set(projects.map((p) => p.name)))}/></TableHead>{columns.map((c) => (<TableHeadkey={c.key}numeric={c.numeric}sort={sort.key === c.key ? (sort.dir === 1 ? "ascending" : "descending") : undefined}><button type="button" onClick={() => setSort({ key: c.key, dir: sort.key === c.key ? (-sort.dir as 1 | -1) : 1 })}>{c.label}</button></TableHead>))}</TableRow></TableHeader><TableBody>{rows.map((p) => (<TableRow key={p.name} picked={picked.has(p.name)}><TableCell><TablePickaria-label={`Choose ${p.name}`}checked={picked.has(p.name)}onChange={() => setPicked((s) => { const n = new Set(s); if (!n.delete(p.name)) n.add(p.name); return n })}/></TableCell><TableCell primary sorted={sort.key === "name"}>{p.name}</TableCell><TableCell sorted={sort.key === "kind"}>{p.kind}</TableCell><TableCell numeric sorted={sort.key === "year"}>{p.year}</TableCell><TableCell numeric sorted={sort.key === "fee"}>{pounds.format(p.fee)}</TableCell></TableRow>))}</TableBody></Table><p aria-live="polite"><span className="ot-yours">{picked.size}</span> of {projects.length} chosen</p></div>)}Contract
- Underneath: native
<table>. - Anatomy:
<table class="ot-table">with header<button>s inside<th aria-sort>, numeric cells markeddata-num,.ot-table-namefor the row's name, and<input type="checkbox" class="ot-table-pick">per row plus one to choose all. - Hairline rows and tabular figures. The sorted column is set in ink (
data-sorted). Re-sorting, the rows glide to their new places (FLIP:TableBodymeasures each row'soffsetTopafter every render and animates the difference, andante on the breath; still under reduced motion). Choosing a row fills its ring with spiccato and its name turns italic (data-picked); the foot counts the picks. varianton the table (data-variant):ink(the default),forteorcross.- forte: SPECTRA's giant type against tiny data. The sorted column is also set loud, in large thin figures (
mf, weight 250;mpbelow 40rem), on the same baselines as the small print of the rest. Re-sorted, the loudness passes from one column to the next (moderato, breath) as the rows glide. - cross: a road atlas's distance chart. Point at a cell, or focus something inside it, and its row and its column cross in ink, their headings too; the cell where they meet is the accent, the one place you are; the rest steps back to pencil. It is a reading aid only: every figure reads in full without it, and screen readers get the table's own headers. Written for up to eight columns (one rule each); below 30rem the chart steps down to small print so it keeps its columns. Forced colours: the meeting cell is Highlight.
- Keyboard: native buttons and checkboxes; in a cross chart, focus inside a cell crosses it.
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
variant | "ink" | "forte" | "cross" | "ink" | ink: the sorted column is set in ink. forte: the sorted column is also set loud, in large thin figures (mf, mp on a phone) on the rows' shared baselines; re-sorted, the loudness passes to the new column. cross: a distance chart; point at a cell, or focus inside it, and its row and column cross in ink, the cell in the accent, the rest in pencil. It reads up to eight columns, and steps down to small print on a phone. |
TableBody | tbody props | Re-sorted, each row glides from where it was to its new place. Nothing travels under reduced motion. | |
TableHead sort | "ascending" | "descending" | Marks the sorted column with aria-sort and turns its heading ink. Put a button inside the heading to make it sortable; the arrow turns with the direction. | |
TableHead numeric | boolean | false | Right-aligns a column of figures. Set the same on its cells. |
TableCell sorted | boolean | false | The cell is in the sorted column, so it is set in ink. |
TableCell primary | boolean | false | The row's name. It turns italic when the row is picked. |
TableRow picked | boolean | false | A chosen row. |
TablePick | checkbox input props | A ring that fills when chosen, and half fills when some are. Give it an aria-label naming the row. | |
TableCaption | caption props | Names the table for screen readers; visually hidden. |
Craft
Where its move comes from
| Component | Reference | Move |
|---|---|---|
| Table | Renaissance two-colour | Only the sorted column in ink |
| Forte table | SPECTRA; the score's forte | The sorted column set loud, in large thin figures, against small print |
| Cross table | A road atlas's distance chart; "Renaissance." two colours | The row and column you point at cross in ink, the meeting cell the accent |
How it moves
| Component | Articulation | What moves |
|---|---|---|
| Table | Glide | The rows glide to their new order; a pick's ring lands |
| Forte table | Crescendo | Re-sorted, the old column falls quiet as the new one swells, while the rows glide |
| Cross table | Ink follows | The crossing inks under the pointer, allegro, and the rest falls to pencil |