Tables and figures
components/ui/table.tsx. The most patched primitive in the kit, and where a
data product lives. Every number below is measured — see 14-measurements.md.
The standard, and it is enforced
Every table pins its header and sorts every column. Not "can" — does.
node tools/standards.mjs fails the build otherwise, and it runs in CI:
stickyHeaderis on by default. Turning it off needs astandards-exempt: sticky — <reason>comment over the table, and the reason gets printed on every run. There is exactly one today: a table inside PROSE, where the document scrolls and the table does not, so there is no port for a header to pin against.- All columns sort, or none do. A partly-sortable table is worse than
neither: a reader cannot tell a column that will not sort from one that is
broken.
useTableSortsupplies both halves — the ordered rows and the props each head needs. - Sort on what the value MEANS, not on what the cell renders. A "last seen" column sorts by the ISO timestamp; sorting the rendered "2h ago" puts "9m" before "2h".
Geometry
| Part | Value |
|---|---|
Header row | 40 px tall |
Body row | 44 px tall |
Compact row | 36 px |
Summary row | 32 px |
Pagination bar | 44 px |
Horizontal padding, every cell | 20 px ( px-5) |
Body text | Inter 400, 14 / 20 px |
Header text | JetBrains Mono 600, 11 px, uppercase, 0.88 px tracking — the mono-caps voice |
Figure cells | JetBrains Mono 400, 14 px, tabular-nums, right-aligned |
The header is an ink band, not a rule under a pale strip: --row-header is
charcoal #18181B with cream ink at 16.7:1 in light, #0F0F11 with warm white
at 17.3:1 in dark. A hairline is enough to end a header on a narrow table and
nowhere near enough on a wide one, and a transparent sticky header smears over
the rows scrolling under it.
Dashboard and table height
A table that is not fill gets a computed height, never a guessed one
(standardTableHeight() in lib/design-tokens.ts):
height = headerRows × 40
+ rows × (compact ? 36 : 44)
+ (summary ? 32 : 0)
+ (pagination ? 44 : 0)
Defaults: 12 rows, one header row → 568 px. Minimum table height 320 px;
24 px of page gap below it. The worst case the layout allows (two header rows,
full rows, summary and pagination) is tableStandardHeightMax.
Inside a Screen fill the table takes the leftover height instead, and the
chain must be sized end to end:
Screen fill → Panel className="min-h-0 flex-1" → Table fill
Without the sized chain the document becomes the scroll port and a correct
sticky header looks broken. Never put overflow on the Panel around a table
— the table owns its port on both axes, and a second scroller gives
position: sticky two ancestors to resolve against.
Zebra, hover, selected — the row ladder
Four surfaces, in this order, measured:
| Step | Light | Dark | Ratio to the row it sits beside |
|---|---|---|---|
--row | #F5F3EF | #2C2C2E | — |
--row-alt (zebra) | #F1EEE8 | #29292B | 1.04:1 |
--row-hover | #DAECE2 | #293D38 | 1.11:1 light · 1.21:1 dark |
--row-selected | #DED8CE | #434346 | 1.28:1 light |
- The zebra is deliberately faint (1.04:1). It is a reading aid across a wide row, not a decoration. If you can see the stripes before you see the data, it is too strong.
- Hover is accent-tinted, not grey. It is the one row-level cue that says "this is the row under your cursor" and it must not read as selection.
- Selected adds a second cue: a 3px inset bar in
--foregroundon the left edge, because colour alone cannot carry a state at 1.28:1. Set it withdata-state="selected"on the row — that is also what marks a row the reader has edited on an admin screen. - Body text stays ≥8.9:1 on every one of those four surfaces, in both themes. That is the constraint the four values were chosen against; if you add a fifth row state, hold it to the same floor.
Sticky header and pinned columns
Both are opt-in and they layer independently:
stickyHeader(default on) pins the header inside the table's own port.pin="left" | "right"on a head and its cells makes a sticky column with an inset 1px border on its inner edge — for the identity column of a wide table.- The z-index ladder is fixed and mutually exclusive: pinned header 30, plain sticky header 20, pinned body cell 10. Do not invent a fourth layer; a pinned head must beat both the pinned column and the rows.
- A pinned cell is
bg-inheritso it keeps its row's zebra, hover and selected colour as it floats.
Sorting
useTableSort(rows, accessors, initial) — typed by TableSortState and
TableSortValue — returns sorted and a sortProps(key)
spreader for the head cell. The rules it encodes:
- Presence of
onSortToggleis what makes a column sortable. No handler, no affordance — never a chevron on a column that does not sort. - First click direction follows the data type: numbers open descending (the biggest first is what a reader wants), text opens ascending. Clicking again flips.
- Empty values sort last in both directions. A null is not a zero and must not lead the ascending list.
- Text compares with
localeCompare(…, { numeric: true }), soCounty 2sorts beforeCounty 10. - One sorted column at a time; the active column shows its direction, the others
show the neutral
ChevronsUpDownin tertiary ink. - The sort control is a real button inside the head cell with a focus ring — the whole header band is not the target.
Column headers say what they count
<TableHead align="number" info="Homes in jurisdictions that meet the cut.">Homes</TableHead>
align="number" right-aligns and switches to mono tabular figures.
info renders the (?) and — this is the accessible part — also writes the
sentence into a hidden node the cell points at with aria-describedby, because
a tooltip on a <th> is never announced to a reader who tabs to the sort button
inside it.
Use info on any column whose name is shorter than its meaning. It is the
cheapest way to keep a data table honest.
Figures
- Mono and tabular, always, so columns line up and a live number does not shift by a pixel as it updates.
- Count first, then what it is worth:
18/24, then92% of homes. - Null renders as an em dash, never as a blank cell or a zero.
- Never colour a figure to mean "good". Colour states, not numbers — the moment a rate is green it has stopped being evidence and become a verdict.
Empty and loading
Skeletons shaped like the table (a title bar and a block its height), never a centred spinner. An empty table gets one row spanning every column, in secondary ink, saying what would put something there — and if it is empty because something is misconfigured, it says that instead.