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.
Playground
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>When to use
Structured, comparable data — users, transactions, orders, standings, audit
logs. It is a semantic <table> primitive set: bring your own data
fetching, sorting logic, selection state and pagination; the compound handles
the visual layer, the sticky machinery, and the a11y contract. For a turnkey
feature layer, put TanStack Table's state on top — the controlled props here
speak its vocabulary.
Reach for something else when:
| You need | Use |
|---|---|
| Editable cells / a spreadsheet | A dedicated grid — no inline inputs in a table (route edits to a modal or page) |
| A record-per-row layout on phones | A list composition (Card / CellAvatar rows) — see Responsive below |
| Two columns of label + value | A description list, not a table |
| Tap-only counter rows, pickers | The corresponding form component |
Responsive doctrine
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. 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.
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.
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.
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 (Carbon: 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 |
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.
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 |
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 |
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.
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.
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 |
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. - Scroll: the region is focusable and named by the caption (WCAG 2.1.1).
- Row deletion: re-home focus yourself (next row's control) — otherwise it
drops to
<body>.
| AT | Status |
|---|---|
| VoiceOver (macOS) | Pending manual pass — badge stays beta |
| NVDA / JAWS | UNTESTED — cannot run on this machine |