# Table
A ruled data table — alignment, sorting and rules all per column.
- Group: Data
- Import: `import { Table } from '@misoto22/folio'`
- Page: https://ui.misoto22.com/components/table/
- Related: card, figure-band
## When to reach for it
Alignment is per column and numbers belong at the end edge, so digits line up. Sorting is opt-in per column: a table where every header is a button invites sorting a column the data cannot be ordered by.
## Anatomy
- **Scroll region** (required) — The focusable
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.
- **Caption** (required) — A real
, 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 label** (required) — 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 INSIDE the th, with ArrowUp, ArrowDown or a dimmed ChevronsUpDown beside the label, and aria-sort on the th set from sortDirection.
- **Cells** (required) — 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 , 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.
## Accessibility
- caption is required: an unnamed table on a page with three tables is unnavigable.
- Column labels are , 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.
## Keyboard
- Tab — Reaches the scroll region, and each sortable column header.
- ← / → — Scrolls the table sideways once the region has focus.
## Table
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.
### Props
- `caption` (required) — `string`. Describes the table for a screen reader, which cannot see the heading above it. Rendered as a visually-hidden `` — required, because an unnamed table in a page with three tables is unnavigable.
- `showCaption` — `boolean` default `false`. Prints the caption instead of hiding it.
- `stickyHeader` — `boolean` default `false`. Pins the header row while the body scrolls. Needs a bounded height on the container — otherwise the page scrolls, not the table, and nothing sticks.
- `borders` — `TableBorders` default `'rows'`. Which rules to draw. See TableBorders.
- `density` — `'comfortable' | 'compact'` default `'comfortable'`. Tightens the row padding, for a table that is mostly numbers.
Also accepts: `TableHTMLAttributes`.
## THead
Header group. The rule under it is drawn by the table's border setting.
## TBody
## TR
## TH
A column label. Mono and uppercase, so it never reads as data. When sortable, the label becomes a `` INSIDE the `` rather than the ` ` 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.
### Props
- `align` — `TableAlign` default `'start'`. Which edge the column's contents sit against. Numbers belong at `end`.
- `sortable` — `boolean` default `false`. Makes the label a button and shows the sort marker.
- `sortDirection` — `SortDirection` default `'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.
- `onSort` — `() => void`.
Also accepts: `Omit, 'align'>`.
## 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.
### Props
- `align` — `TableAlign` default `'start'`. Which edge the cell's contents sit against. Match the column's header.
Also accepts: `Omit, 'align'>`.
## Example — a sortable column
```tsx
import { Badge, TBody, TD, TH, THead, TR, Table, type SortDirection } from '@misoto22/folio'
Commit
Branch
{/* Sorting is opt-in per column: a table where every header is a
button invites sorting a column the data cannot be ordered by. */}
setSort(sort === 'ascending' ? 'descending' : 'ascending')}
>
Duration
State
{rows.map((row) => (
{row.sha}
{row.branch}
{/* Numbers belong at the end edge, so their digits line up. */}
{format(row.seconds)}
{row.state}
))}
```
## Example — borders
```tsx
import { TBody, TD, TH, THead, TR, Table, type TableBorders } from '@misoto22/folio'
{OPTIONS.map((option) => (
{option.value} — {option.note}
Region
Orders
Revenue
Australia
1,204
$48,210
Japan
862
$31,940
))}
```
## Example — a sticky header
```tsx
import { TBody, TD, TH, THead, TR, Table } from '@misoto22/folio'
Commit
Branch
Duration
{RUNS.map((run) => (
{run.sha}
{run.branch}
{format(run.seconds)}
))}
```
## Example — seven columns
```tsx
import { TBody, TD, TH, THead, TR, Table } from '@misoto22/folio'
Host
Region
Version
CPU
Memory
p95
Uptime
{HOSTS.map((host) => (
{host.host}
{host.region}
{host.version}
{host.cpu}
{host.memory}
{host.p95}
{host.uptime}
))}
```