Data
Table
A ruled data table — alignment, sorting and rules all per column.
Usage
When to reach for it
import { Table } from '@misoto22/design'Notes
A ruled data table.
Wrapped in its own horizontally scrolling container, because a table is the one block that legitimately exceeds the measure — and a page that scrolls sideways as a whole is a layout bug, while a table that does is a table.
That container is focusable, and it has to be. A scrollable region whose contents are not themselves focusable is unreachable by keyboard: there is no element to Tab to and therefore no way to press an arrow key at it, so the columns past the fold simply do not exist for anyone not using a mouse.
It is also POSITIONED, which is not decoration. sr-only is position: absolute, so a visually-hidden label in a cell resolves against the nearest positioned ancestor — and with none between it and the document, it escapes the scroll container and every overflow-hidden around it, and widens the page by however far the table happens to be scrolled.
No zebra striping at any border setting. In a monochrome system a striped row is a second surface competing with the page ground, and the hairline between rows is already enough to track a line across.
Anatomy
| Element | Description |
|---|---|
| Scroll regionrequired | The focusable <div role="region"> around the table, named by caption. It carries the border setting and the density attribute, and it is what scrolls sideways — so the table exceeds the measure and the page does not. It is also positioned, which is what keeps an sr-only label inside a cell from resolving against the document and dragging the page sideways with it. |
| Captionrequired | A real <caption>, visually hidden unless showCaption prints it as an eyebrow above the table. The same string is the scroll region’s accessible name, so it is heard on the way in and again from the table. |
| Column labelrequired | TH — mono and uppercase so it never reads as data, aligned per column, and always emitting scope="col" unless a call site overrides it. |
| Sort control | On a sortable header only: a <button> INSIDE the th, with ArrowUp, ArrowDown or a dimmed ChevronsUpDown beside the label, and aria-sort on the th set from sortDirection. |
| Cellsrequired | TD — top-aligned at --ink-2, sharing --table-pad-x with the header so the columns line up, and taking their row height from --table-pad-y, which density halves from 14px to 8px. |
Best practices
Do
- Bound the height from outside for stickyHeader: className and every other prop land on the <table>, not on the scrolling div around it, so only a constraining parent — a flex column with a height — gives that div something to stick within. A max-height on a plain wrapper does not, and the header simply travels with the page.
- Pass scope="row" on a row’s first cell — TH writes scope="col" and your props are spread after it, so the override lands; without it every row header claims to head a column and a cell traced back reaches the wrong label.
- Reset the other columns to sortDirection="none" when the sort moves: each header carries its own aria-sort and nothing coordinates them, so a table can end up announcing two columns as sorted at once.
- Give TD the same align as its TH — alignment is per cell, not inherited down the column, and end-aligned numbers under a start-aligned label is a column whose digits stop lining up with their own heading.
Don’t
- aria-sort tells a reader how the table is ordered once they reach the header; it announces nothing at the moment the button is pressed. A table that re-orders under a screen reader has to say so somewhere the reader already is, or every row silently changes and nothing is said.
- Do not expect it to reflow on a phone: nothing stacks, the region scrolls sideways behind a hairline scrollbar, and a column past the fold is reachable only by a reader who works out that it scrolls. Eight columns at 375px wants a different presentation, not a smaller font.
Examples
a sortable column
Sorting is opt-in per column, because a table where every header is a button invites sorting a column the data cannot be ordered by. A sortable header renders a real button INSIDE the th — a click handler on the cell would not be focusable and would not be announced, so the sort would exist for a mouse and for nothing else — and sets aria-sort from sortDirection, which is the only way a screen reader learns the table is ordered at all. Each header carries its own aria-sort and nothing coordinates them, so reset the others to none when the sort moves. caption is required and hidden by default: it names the scroll region as well as the table.
| Commit | Branch | State | |
|---|---|---|---|
| a1b2c3d | main | 2m 14s | passed |
| 9f8e7d6 | codex/ui-library | 2m 41s | passed |
| 4c5b6a7 | codex/photo-cache | 1m 02s | failed |
| 77aa2b1 | main | 2m 20s | passed |
borders
The four border settings on the same two rows, so the rules are the only thing changing. rows is the default and the right answer for reading down a column: one hairline between records and nothing competing with the line the eye is tracking. grid adds the vertical rules a dense numeric table needs; bordered draws an edge around the whole thing, for a table loose on a page rather than inside a card that already has one. There is no zebra striping at any setting — in a monochrome system a striped row is a second surface competing with the page ground. density compact halves the row padding from 14px to 8px, which is what a table that is mostly numbers wants.
rows — the default — read down a column
| Region | Orders | Revenue |
|---|---|---|
| Australia | 1,204 | $48,210 |
| Japan | 862 | $31,940 |
grid — dense numbers, columns kept apart
| Region | Orders | Revenue |
|---|---|---|
| Australia | 1,204 | $48,210 |
| Japan | 862 | $31,940 |
bordered — loose on a page, not inside a card
| Region | Orders | Revenue |
|---|---|---|
| Australia | 1,204 | $48,210 |
| Japan | 862 | $31,940 |
bordered-grid — both
| Region | Orders | Revenue |
|---|---|---|
| Australia | 1,204 | $48,210 |
| Japan | 862 | $31,940 |
a sticky header
stickyHeader needs a bounded height on a container the component does not own. Every prop, className included, lands on the table element itself — not on the scrolling div wrapped around it — so only a constraining PARENT gives that div something to stick within. This is the arrangement that works: a flex column with a fixed height. The scroll region is a flex item and a scroll container, so its automatic minimum size is zero and it shrinks to the height on offer. A max-height on a plain block wrapper does not do it, and the header simply travels up the page with everything else.
| Commit | Branch | Duration |
|---|---|---|
| 9a2f41c | main | 0m 48s |
| 9a3130b | codex/step-1 | 1m 25s |
| 9a331fa | codex/step-2 | 2m 02s |
| 9a350e9 | codex/step-3 | 2m 39s |
| 9a36fd8 | main | 0m 56s |
| 9a38ec7 | codex/step-5 | 1m 33s |
| 9a3adb6 | codex/step-6 | 2m 10s |
| 9a3cca5 | codex/step-7 | 2m 47s |
| 9a3eb94 | main | 1m 04s |
| 9a40a83 | codex/step-9 | 1m 41s |
| 9a42972 | codex/step-10 | 2m 18s |
| 9a44861 | codex/step-11 | 2m 55s |
| 9a46750 | main | 1m 12s |
| 9a4863f | codex/step-13 | 1m 49s |
| 9a4a52e | codex/step-14 | 2m 26s |
| 9a4c41d | codex/step-15 | 3m 03s |
| 9a4e30c | main | 1m 20s |
| 9a501fb | codex/step-17 | 1m 57s |
| 9a520ea | codex/step-18 | 2m 34s |
| 9a53fd9 | codex/step-19 | 0m 51s |
| 9a55ec8 | main | 1m 28s |
| 9a57db7 | codex/step-21 | 2m 05s |
| 9a59ca6 | codex/step-22 | 2m 42s |
| 9a5bb95 | codex/step-23 | 0m 59s |
seven columns
Seven columns, printed caption, and row headers. showCaption turns the hidden caption into an eyebrow above the table; the same string is the scroll region's accessible name either way, so it is heard on the way in and again from the table. Pass scope="row" on each row's first cell — TH writes scope="col" and your props are spread after it, so the override lands, and without it every row header claims to head a column. Nothing here reflows on a phone: the region scrolls sideways behind a hairline bar and the columns past the fold are reachable only by a reader who works out that it scrolls. Narrow this canvas and see. Seven columns at 375px wants a different presentation, not a smaller font.
| Host | Region | Version | CPU | Memory | p95 | Uptime |
|---|---|---|---|---|---|---|
| api | ap-southeast-2 | 0.4.0 | 12% | 1.4 GB | 84 ms | 31d |
| web | ap-southeast-2 | 0.4.0 | 7% | 0.9 GB | 41 ms | 31d |
| worker | ap-northeast-1 | 0.3.1 | 38% | 2.1 GB | — | 6d |
Parts
Composed at the call site rather than configured through props, so a layout this component did not anticipate is still expressible.
THead
Header group. The rule under it is drawn by the table's border setting.
Takes no props of its own.
TBody
Takes no props of its own.
TR
Takes no props of its own.
TH
A column label. Mono and uppercase, so it never reads as data.
When sortable, the label becomes a <button> INSIDE the <th> rather than the <th> becoming clickable: a cell with a click handler is not focusable and not announced as a control, so the sort exists only for a mouse.
Sorting is per column and opt-in. A table where every header is a button invites the reader to try sorting a column the data cannot be ordered by.
| Prop | Type | Default | Description |
|---|---|---|---|
| align | TableAlign | 'start' | Which edge the column's contents sit against. Numbers belong at `end`. |
| onSort | () => void | — | |
| sortable | boolean | false | Makes the label a button and shows the sort marker. |
| sortDirection | SortDirection | 'none' | Which way this column is sorted. Also set as `aria-sort`, which is the only way a screen reader learns a table is ordered at all — a caret drawn in the header tells it nothing. |
Also accepts everything in Omit<ThHTMLAttributes<HTMLTableCellElement>, 'align'>. Those are forwarded to the underlying element and are not listed row by row.
TD
A body cell.
TOP-aligned, and that is a choice about which kind of table is the common one here: a cell holding a paragraph. Top is what lines the first lines of a row up with each other, and centring a two-word term against a four-line description puts the term in the middle of nothing.
It is the wrong default for the other kind — a row of one-line values with a control in it. A 36px button next to 16px of text makes a 52px row, and every other cell then hangs at the top of it with twenty pixels of nothing underneath, which reads as a column that has slipped. Pass className="align-middle" on those rows; the data-table template does.
| Prop | Type | Default | Description |
|---|---|---|---|
| align | TableAlign | 'start' | Which edge the cell's contents sit against. Match the column's header. |
Also accepts everything in Omit<TdHTMLAttributes<HTMLTableCellElement>, 'align'>. Those are forwarded to the underlying element and are not listed row by row.
Types
export type TableAlign = 'start' | 'center' | 'end'
export type TableBorders = 'rows' | 'grid' | 'bordered' | 'bordered-grid' | 'none'
export type SortDirection = 'ascending' | 'descending' | 'none'Keyboard
| Key | Does |
|---|---|
| Tab | Reaches the scroll region, and each sortable column header. |
| ←→ | Scrolls the table sideways once the region has focus. |
Accessibility
- caption is required: an unnamed table on a page with three tables is unnavigable.
- Column labels are <th scope="col">, so a cell can be traced back to its heading.
- A sortable header is a button INSIDE the th, not a click handler on the cell — a cell with an onClick is not focusable and not announced, so the sort would exist only for a mouse.
- aria-sort is set from sortDirection, which is the only way a screen reader learns the table is ordered at all.
- No zebra striping at any border setting: in a monochrome system a striped row is a second surface competing with the page ground.
- The scroll region is a containing block, so a visually-hidden label in a cell stays inside the table rather than escaping it and widening the page.