Architect
Practices
ReferenceNormative

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 blank

These 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

FailureWhat it costs
Every list builds its own tableThree table dialects in one product
A status dot without its wordPasses review, fails accessibility and daylight
A wide labelled button in the last columnThe most expensive element in the row, and the least used
Stacked cellsA fifth of the table's height, spent on a hierarchy the columns already state

On this page