All documents

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:

  • stickyHeader is on by default. Turning it off needs a standards-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. useTableSort supplies 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

PartValue
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:

StepLightDarkRatio 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 --foreground on the left edge, because colour alone cannot carry a state at 1.28:1. Set it with data-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-inherit so 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 onSortToggle is 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 }), so County 2 sorts before County 10.
  • One sorted column at a time; the active column shows its direction, the others show the neutral ChevronsUpDown in 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, then 92% 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.