X Data

Star on GitHubFeedback
X DataNative HTMLot-table

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 Table

Example

Ink
Fees by project
NorthlightWeb2026£62,000
TidewaterMotion2025£54,000
HaldenIdentity2026£48,000
MarramWeb2025£41,000
Oda StudioIdentity2025£36,000
Felt & FieldIdentity2024£33,000
Quiet HoursWeb2024£29,000

0 of 7 chosen

Forte
Fees by project
NorthlightWeb2026£62,000
TidewaterMotion2025£54,000
HaldenIdentity2026£48,000
MarramWeb2025£41,000
Oda StudioIdentity2025£36,000
Felt & FieldIdentity2024£33,000
Quiet HoursWeb2024£29,000

0 of 7 chosen

Cross
Distances between the studios, in kilometres
FromOsloBaselLisbonLagosKyoto
Oslo1,3702,7405,9808,570
Basel1,3701,5604,5209,600
Lisbon2,7401,5603,80011,000
Lagos5,9804,5203,80013,300
Kyoto8,5709,60011,00013,300
Forte, sorted by fee
ProjectFee
Northlight£62,000
Tidewater£54,000
Halden£48,000
Cross, pointed at Lisbon to Kyoto
Distances between the studios, in kilometres
FromOsloBaselLisbonLagosKyoto
Oslo1,3702,7405,9808,570
Basel1,3701,5604,5209,600
Lisbon2,7401,5603,80011,000
Lagos5,9804,5203,80013,300
Kyoto8,5709,60011,00013,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.json

Usage

table-example.tsx
"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 marked data-num, .ot-table-name for 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: TableBody measures each row's offsetTop after 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.
  • variant on the table (data-variant): ink (the default), forte or cross.
  • forte: SPECTRA's giant type against tiny data. The sorted column is also set loud, in large thin figures (mf, weight 250; mp below 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

Props of Table
PropTypeDefaultWhat 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.
TableBodytbody propsRe-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 numericbooleanfalseRight-aligns a column of figures. Set the same on its cells.
TableCell sortedbooleanfalseThe cell is in the sorted column, so it is set in ink.
TableCell primarybooleanfalseThe row's name. It turns italic when the row is picked.
TableRow pickedbooleanfalseA chosen row.
TablePickcheckbox input propsA ring that fills when chosen, and half fills when some are. Give it an aria-label naming the row.
TableCaptioncaption propsNames the table for screen readers; visually hidden.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
TableRenaissance two-colourOnly the sorted column in ink
Forte tableSPECTRA; the score's forteThe sorted column set loud, in large thin figures, against small print
Cross tableA road atlas's distance chart; "Renaissance." two coloursThe row and column you point at cross in ink, the meeting cell the accent

How it moves

How it moves
ComponentArticulationWhat moves
TableGlideThe rows glide to their new order; a pick's ring lands
Forte tableCrescendoRe-sorted, the old column falls quiet as the new one swells, while the rows glide
Cross tableInk followsThe crossing inks under the pointer, allegro, and the rest falls to pencil