Registers
Tables an operator sweeps a hundred times a day — one line per row, actions in a menu, and three invariants that decide every cell.
A register is a list somebody scans, not a document somebody reads. Every pixel of row height is a row they do not see. That single constraint decides most of what follows.
One kit, every list
No screen invents its own table. A small set of primitives, used everywhere, so column heads, padding and hover behaviour cannot drift apart.
Table frame: hairline, surface, contained horizontal overflow
Th column head: micro-caps, wide tracking, no wrap
Tr row: hairline below, hover tint, last row unruled
Td cell: consistent padding, middle-aligned
Status a dot AND a word
Tag neutral chip: a role, a category, an attachment
Identity what you look for, then what disambiguates it — on one line
Empty one sentence where rows would be
None what goes in a cell with no value — never a blankThese are design-system components by the promotion test: each one encodes a product-wide decision about how lists behave, and none of them names a domain.
Three invariants
Never an empty cell. "None", "Never", "—". A blank cell is ambiguous between no value, not applicable and failed to load, and the reader has to guess which.
Never a decorative zero. A metric reading 0 teaches nothing. Omit it, or say
what is actually true.
Colour never carries meaning alone. A status is a dot and a word. The dot alone is a colour code, which neither a colour-blind reader nor a screen reader can resolve; the word alone disappears in a dense column. Together they read both ways. See colour is reserved for status.
One line per row
This is the rule that gets broken quietly and costs the most.
A cell stacking a label over its code, a date over its author, a name over an email is two facts arranged in a hierarchy the columns already express. In a table the column head does the hierarchy. Stacked, a row costs about 59px; flat, about 47px — a fifth of the table's height, for nothing.
{/* stacked — a role and its key read as two entries */}
<Td>
<span className="block font-medium">{role.label}</span>
<span className="mt-0.5 block font-mono text-[11px]">{role.key}</span>
</Td>
{/* one line — the same role, written twice: once for people, once for machines */}
<Td>
<span className="flex min-w-0 items-baseline gap-2.5">
<span className="font-medium whitespace-nowrap">{role.label}</span>
<span className="font-mono text-[11px] whitespace-nowrap text-muted">{role.key}</span>
</span>
</Td>Where two facts genuinely will not fit, give the second one a column, not a second line. If that pushes the table past its measure, the row's action is almost always what to shrink — see below — not the data.
The one thing that may truncate is the disambiguator: an email under a long name.
Truncate it with min-w-0 truncate and a title. Never the name.
Measure what actually sets the row height before optimising it. A small button in the last column is 32px and floors the row at ~52px whatever the cells do. Flatten the cells and nothing moves until the button goes too.
Row actions live in a menu
A ⋮ menu, never inline buttons. Three commands per row across four lists fills
half the table with controls used once a week, while the label — read every time —
gets squeezed. The menu returns that width to the data. A labelled action button
costs about 7rem; the menu about 5rem, and that difference is often exactly what a
column set needs to fit.
<Td className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button aria-label={`Actions on ${row.label}`} size="icon" variant="ghost">
<DotsThreeVerticalIcon aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">…</DropdownMenuContent>
</DropdownMenu>
</Td>A refused action is absent, not disabled. A greyed-out item explains nothing and leaves the reader to infer why. Remove it, and let the row's own columns carry the reason — the dependants count blocking a deletion, the status blocking a revocation. The data already says it; the disabled control only repeats that something is wrong.
Dates in a column
Use the medium style, not the long one. 18 Aug 2026, 00:55 says what
18 August 2026 at 00:55 says in thirty fewer pixels, and thirty pixels is often
what lets the author's name stay on the same line. Keep the time only where it means
something — a validity measured in hours. Drop it where the day is the unit.
How it fails
| Failure | What it costs |
|---|---|
| Every list builds its own table | Three table dialects in one product |
| A status dot without its word | Passes review, fails accessibility and daylight |
| A wide labelled button in the last column | The most expensive element in the row, and the least used |
| Stacked cells | A fifth of the table's height, spent on a hierarchy the columns already state |