Table
A semantic table compound — sortable columns with announced results, checkbox selection with a batch bar, row groups, expandable rows, sticky header/footer/column done correctly, and the cell helpers real products keep rebuilding.
pnpm add @tessinaui/uiWhen to use
- Structured, comparable data: users, transactions, orders, standings, audit logs, where people scan across rows and down columns.
- Data where each row is one item and each column is one fact about it, so a reader can find a record and compare it with its neighbours.
- Sorting, selection or grouping on a list of records: sortable headers, a checkbox column with a batch bar, row groups and expandable detail rows are built in.
- Your own data layer. This is a semantic
<table>primitive set: bring your own fetching, sorting, selection state and pagination. For a turnkey feature layer, put TanStack Table's state on top (the controlled props speak its vocabulary) or take DataGrid, which does exactly that.
When not to use
- For page layout. A table is for data, not for arranging content; use Grid or Flex.
- For editable cells. Use DataGrid with an
editablecolumn. A table has no inline inputs; without DataGrid, route edits to a Modal or a page. - For arrow-key cell navigation, a filter bar, column and density menus or CSV export. Use DataGrid, the
role="grid"product built on these parts. - For two columns of label and value. Use a description list, not a table.
- For a record-per-row layout on phones. Use a list composition (Card or CellAvatar rows); see Responsive.
- For tap-only counter rows or pickers. Use the corresponding form component.
Installation
pnpm add @tessinaui/uiUsage
import {
TableRoot, TableHeader, TableBody, TableFooter,
TableRow, TableHead, TableCell, TableCheckboxCell,
TableCaption, TableEmpty,
TableGroup, TableGroupHeader,
TableExpandButton, TableExpandedRow, TableBatchBar,
TableCellAvatar, TableCellAmount, TableCellStatus, TableCellActions,
} from "@tessinaui/ui";<TableRoot variant="lined">
<TableCaption>Team members</TableCaption>
<TableHeader>
<TableRow hoverable={false}>
<TableCheckboxCell header checked={all} indeterminate={some} onCheckedChange={toggleAll} />
<TableHead sortable sortDirection={dir} onSort={setDir}>Name</TableHead>
<TableHead numeric>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.id} selected={isSelected(r.id)}>
<TableCheckboxCell checked={isSelected(r.id)} onCheckedChange={toggle(r.id)} label={`Select ${r.name}`} />
<TableCell rowHeader>{r.name}</TableCell>
<TableCell numeric>
<TableCellAmount currency="$" amount={r.amount} />
</TableCell>
</TableRow>
))}
</TableBody>
</TableRoot>Curving your own content
Content you put inside — an avatar or icon bubble in a cell — should curve like the chrome around it, or it reads as pasted on. The table container publishes its inner radius as a CSS variable:
<img className="rounded-[var(--table-item-radius)]" />It tracks whatever the component is actually doing: the explicit step when rounded is set (4px at md), and 0 at rounded="none" — so your content squares off exactly when the container does.
Examples
Default
Avatar, status, amount and actions cell helpers in one team table; the name column is the row header.
Variants
lined, striped, bordered, borderless. Inside a coloured Surface a
framed variant paints its own page surface. borderless paints nothing, so it
reads the plate: its text takes the plate's ink, the header, footer and group
headers draw a hairline, a row hovers to a hairline and a selected row becomes
the inverse chip. It paints no wash under a cell there — an intent row keeps
its accent strip and a highlighted column stays bare — because a second wash
under a cell's own badge would drop the badge's label below AA.
Sizes
Five densities. Type follows the data-display ladder (12/12/14/14/16).
Sortable columns
Tri-state cycle (none → asc → desc → none), aria-sort on the header cell,
the button fills the whole cell, and the result is announced from a polite
live region — aria-sort alone is not reliably read on activation.
Selection + batch actions
Controlled checkbox column with an indeterminate select-all, per-row accessible names, and the batch bar that appears with a selection.
Row states
selected, disabled, hover override.
Row intents
Status-tinted rows via the §1 vocabulary — highlight is not selection. Add
accent for the 3px start-edge strip (log/severity queues); it is an inset
box-shadow, so it never shifts layout and mirrors under RTL.
Comparison matrix
TableColGroup/TableCol are the real <colgroup>/<col> elements —
highlighted tints the recommended column from under the cells, width sizes
columns without header markup. TableCellCheck names the icon-only ✓/—/✗
cells ("Included"/"Not included" by default) that every feature-comparison
table otherwise ships silent. For a spanning group header row (a <th colSpan>
labelling several columns), give it scope="colgroup" — TableHead forwards
both.
Progress cells
TableCellProgress composes the house Progress at table density — completion,
usage and quota columns with a tabular percentage caption.
Crosstab
Dual-axis matrices (time-tracking weeks, budget periods) are composition, not
API: entity rows × period columns, a consumer-emphasised row-total column, and
a TableFooter for the column totals.
Row groups
Each group is its own <tbody> with a scope="rowgroup" header — the
grouped-by-date shape of every transaction list; collapsible, with trailing
totals.
Expandable rows
A real chevron button (aria-expanded + aria-controls) revealing a
persistent detail row. With a checkbox column, the expand column comes first.
Sticky header, footer and column
Sticky sticks to the container's scrollport — maxHeight creates it. The
frozen column paints the row-owned background variable, so stripe, hover and
selection follow into it.
Caption + scrollable region
The caption names the table and the focusable scroll region; row headers keep wide rows announced correctly.
Pagination
Composes the Pagination component and shows the full
rowCount/ariaRowIndex pairing for a windowed dataset. Layout doctrine:
a full-width bar — visible results summary at the start, pager at the end
(the dominant product pattern) — wrapping to centered on phones.
Mobile-fit table
When a table earns its place on a phone.
Empty & loading
TableEmpty carries its next action; TableSkeleton mirrors columns, rows,
density and the avatar column.
Load error
TableEmpty with intent="error" is the data-load failure: error glyph and ink, and
its title is announced through the table's own status region, which is mounted
empty with the table — a region inserted already holding the message would be silent.
A retry that fails again is announced again. When title is not plain text, pass the
words to speak as announcement.
On color
A borderless table paints nothing, so inside a coloured Surface its parts read the plate: hairlines instead of bands, intent inks for status, and the inverse chip for a selected row. The framed variants paint a page panel and keep the page's look.
RTL
Set dir="rtl" and the column order mirrors, so the first column sits at the
right and row actions at the left. numeric cells align to the logical end,
which lines amounts up along the left edge. Leave dir unset and the table
follows the page.
API Reference
TableRoot
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Cell padding + the data-display type ladder |
variant | "lined" | "striped" | "bordered" | "borderless" | "lined" | Frame treatment |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Container radius (§2 canonical; full is the 24px panel radius) |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Container border accent. "primary" is deprecated (renders unchanged + dev-warn) |
dir | "ltr" | "rtl" | inherited | Only rendered when set — no forced LTR |
hoverable | boolean | true | Row hover wash (helps scanning even in static tables) |
stickyHeader / stickyFooter | boolean | false | Per-cell sticky against the container scrollport — requires maxHeight (dev-warn otherwise) |
stickyOffset | number | 0 | Extra top offset for sticky header cells (app-level fixed headers) |
maxHeight | number | string | — | Bounds the container = creates the vertical scrollport |
withContainer | boolean | true | Auto-wrap in TableContainer |
scrollportTabStop | boolean | true | Forwarded to the automatic TableContainer — see below |
verticalAlign | "middle" | "top" | "bottom" | "middle" | Body cell alignment |
columnDividers | boolean | false | Vertical rules on any variant |
rowCount | number | — | aria-rowcount for windowed data: total rows of the FULL dataset including the header row; -1 if unknown. Pair with ariaRowIndex on every row — one without the other is worse than neither |
TableContainer
Standalone scroll wrapper (auto-rendered by Root). Scrollable containers are
keyboard-focusable (tabIndex=0) and exposed as a named region — the
name comes from TableCaption automatically, or pass aria-label
(dev-warn when scrollable and unnamed). Also takes maxHeight, and
scrollportTabStop={false} for a table whose own cells take focus: the
scrollport then drops its Tab stop, its region landmark and the naming
warning, because a role="grid" owns one Tab stop and scrolls by moving it
(DataGrid uses it).
TableRow
| Prop | Type | Default | Description |
|---|---|---|---|
selected | boolean | — | Visual selection wash + data-selected. Semantics live on the row checkbox — aria-selected is invalid on <tr> outside grids |
disabled | boolean | — | Dimmed + aria-disabled |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Status wash + data-intent. Not selection. Visual only — nothing is exposed to assistive tech, so the row must also state its status as text (a status or badge cell) |
accent | boolean | — | 3px start-edge strip in the intent color (needs intent) — inset shadow, no layout shift, RTL-mirrored |
destructive | boolean | — | Deprecated — intent="error" |
hoverable | boolean | inherited | Per-row override |
ariaRowIndex | number | — | 1-based absolute position in the full dataset (header row = 1) |
TableHead
| Prop | Type | Default | Description |
|---|---|---|---|
sortable | boolean | — | Renders the full-cell sort button; aria-sort lives on the <th> ("none" while sortable-unsorted, absent when not sortable) |
sortDirection | "none" | "asc" | "desc" | "none" | Controlled direction |
onSort | (next) => void | — | Receives the next step of the tri-state cycle |
formatSortAnnouncement | (label, next) => string | English sentence | Replaces Sorted by {label}, ascending / Sorting removed from {label} in the polite region — for a page in another language |
numeric | boolean | — | End-aligned + tabular numerals — on the header too (the column pair is one rule) |
align | "start" | "center" | "end" | "start" | |
sticky | boolean | — | Freeze at the start edge |
width | number | string | — | Width hint |
Never nest other interactive controls inside a sortable header — the sort button owns the cell. Filters belong in a toolbar above the table.
A header whose press does something other than sort can take the same button
and glyph instead of copying them: tableHeadButtonClasses({ size, rounded, align, onColor })
returns the full-cell button's classes (its inset focus ring and 44px touch
floor included), and <TableSortIndicator direction onColor /> draws the sort
glyph. DataGrid's column-menu button is built from both.
TableColGroup / TableCol
Real <colgroup>/<col> elements. TableCol takes width and
highlighted — the plan-comparison column wash, painted under cell and row
backgrounds so hover/stripe/selection still read. Decorative: mark the
recommended column's content too.
TableCell
align / numeric / truncate (native title reveals the clipped text) /
sticky / width, plus rowHeader — renders <th scope="row">, the
row's own name for screen readers and for checkbox labels.
TableCheckboxCell
header (select-all slot: tri-state via indeterminate) · checked ·
onCheckedChange · disabled · label — give every row checkbox a
per-row name (Select ${name}); defaults are "Select all rows" / "Select
row". Toggling never doubles as a row click.
TableGroup / TableGroupHeader
TableGroup is a <tbody> (prop: collapsed); TableGroupHeader renders a
scope="rowgroup" header spanning colSpan columns, with a trailing slot
for group totals and an optional collapse button (expanded +
onExpandedChange — a real aria-expanded control).
TableExpandButton / TableExpandedRow
The chevron is a labelled button with aria-expanded and aria-controls;
the detail row (id, expanded, colSpan) stays in the DOM while hidden so
the reference always resolves. Order the expand column before the checkbox
column.
TableBatchBar
count (announced politely) · formatCount · onCancel / cancelLabel ·
children = bulk action buttons. Render it when selection > 0; disable
row-level actions while it is shown.
TableEmpty
colSpan (required) · icon · title · children · action — an empty
state should carry its next step. intent ("none" | "error") — "error" is
the load-failure presentation and announces the message · announcement — the
plain text to speak; defaults to title when it is a string.
Cell helpers
TableCellText · TableCellAvatar · TableCellAmount (now with intent
for signed coloring, plus currency/suffix/icon/delta/deltaIntent) ·
TableCellStatus (§1 intents; "neutral"/"primary" deprecated) ·
TableCellCheck (value · negative="minus" | "x" · label — sr-only
"Included"/"Not included" behind the icon) · TableCellProgress (value ·
label caption, null hides · intent · pass aria-label to name the
progressbar) · TableCellActions. Composition helpers — for anything richer,
compose Badge, Avatar, charts directly in cells.
TableSkeleton
rows · columns · avatarColumn · showCaption · showFooter + the same
size/variant/rounded — mirror the real table's shape so the swap
doesn't shift layout.
Migration
| Old | New |
|---|---|
<TableRow destructive> | <TableRow intent="error"> |
TableRoot intent="primary" | pick a §1 intent (alias renders + warns for one minor) |
TableCellStatus intent="neutral" | intent="none" |
rounded visuals | container corners follow §2 (sm 4px · md 8px · lg 12px · full 24px) |
rowCount = body rows | rowCount = full dataset + header row |
| Sticky header "just worked" claims | pass maxHeight — the overflow wrapper is the scrollport |
Responsive
There is no auto-collapsing card mode, deliberately: restyling table internals strips table semantics from the accessibility tree (Safari drops them for every display override), and no first-party design system ships one. Instead:
- Few, narrow, numeric columns — keep the table;
size="xs"+numericfits standings-style data at 320px (see the Mobile example). - Wide record-per-row tables — horizontal scroll works out of the box:
the container is keyboard-focusable and named by the caption or the
root's
aria-label. On phones, prefer switching to a list composition at your breakpoint. - Hiding columns responsively (
max-md:hiddenon a Head/Cell pair) is fine — if you also virtualize or paginate, remember hidden columns change thearia-colcountstory.
Accessibility
- Semantic table, not a grid — natural tab order, no arrow-key
navigation, by design: any keyboard nav would oblige the full
role="grid"pattern. Focusables in cells are ordinary tab stops. - Sort:
aria-sorton the<th>+ a shared polite live region announcing "Sorted by X, ascending" — activation results are otherwise unreliable in AT. - Selection: the checkbox is the semantic; the row wash is a redundant
visual cue (
data-selected), neveraria-selected. - Muted text you add to a cell steps like the cell helpers':
text-foreground/70 dark:text-muted-foreground.text-muted-foregroundis calibrated to 4.5:1 on the bare page, so every row wash — hover, selection, stripes, intent rows, a highlighted column — takes it under AA in light. - Name every table. The scroll region is a tab stop (WCAG 2.1.1), and it
takes its name from a
TableCaption, or from anaria-labeloraria-labelledbyonTableRoot. Without one, the stop is announced as nothing at all; a development build warns. - Row deletion: re-home focus yourself (next row's control) — otherwise it
drops to
<body>.
| AT | Status |
|---|---|
| VoiceOver (macOS, Safari) | Passed 2026-09-14. Cells read their position ("row 1 of 4, column 1 of 3"); a sortable header reads "Name, button", and each press announces "Sorted by Name, ascending" / "Sorted by Name, descending" |
| NVDA / JAWS | UNTESTED — cannot run on this machine |