DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CardCarouselTableDataGridCodeBlock
AI agentsContributing
  1. Components
  2. Table

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/ui

When 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 editable column. 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import {
  TableRoot, TableHeader, TableBody, TableFooter,
  TableRow, TableHead, TableCell, TableCheckboxCell,
  TableCaption, TableEmpty,
  TableGroup, TableGroupHeader,
  TableExpandButton, TableExpandedRow, TableBatchBar,
  TableCellAvatar, TableCellAmount, TableCellStatus, TableCellActions,
} from "@tessinaui/ui";
example.tsx
<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:

example.tsx
<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

PropTypeDefaultDescription
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"inheritedOnly rendered when set — no forced LTR
hoverablebooleantrueRow hover wash (helps scanning even in static tables)
stickyHeader / stickyFooterbooleanfalsePer-cell sticky against the container scrollport — requires maxHeight (dev-warn otherwise)
stickyOffsetnumber0Extra top offset for sticky header cells (app-level fixed headers)
maxHeightnumber | string—Bounds the container = creates the vertical scrollport
withContainerbooleantrueAuto-wrap in TableContainer
scrollportTabStopbooleantrueForwarded to the automatic TableContainer — see below
verticalAlign"middle" | "top" | "bottom""middle"Body cell alignment
columnDividersbooleanfalseVertical rules on any variant
rowCountnumber—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

PropTypeDefaultDescription
selectedboolean—Visual selection wash + data-selected. Semantics live on the row checkbox — aria-selected is invalid on <tr> outside grids
disabledboolean—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)
accentboolean—3px start-edge strip in the intent color (needs intent) — inset shadow, no layout shift, RTL-mirrored
destructiveboolean—Deprecated — intent="error"
hoverablebooleaninheritedPer-row override
ariaRowIndexnumber—1-based absolute position in the full dataset (header row = 1)

TableHead

PropTypeDefaultDescription
sortableboolean—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) => stringEnglish sentenceReplaces Sorted by {label}, ascending / Sorting removed from {label} in the polite region — for a page in another language
numericboolean—End-aligned + tabular numerals — on the header too (the column pair is one rule)
align"start" | "center" | "end""start"
stickyboolean—Freeze at the start edge
widthnumber | 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

OldNew
<TableRow destructive><TableRow intent="error">
TableRoot intent="primary"pick a §1 intent (alias renders + warns for one minor)
TableCellStatus intent="neutral"intent="none"
rounded visualscontainer corners follow §2 (sm 4px · md 8px · lg 12px · full 24px)
rowCount = body rowsrowCount = full dataset + header row
Sticky header "just worked" claimspass 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" + numeric fits 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:hidden on a Head/Cell pair) is fine — if you also virtualize or paginate, remember hidden columns change the aria-colcount story.

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-sort on 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), never aria-selected.
  • Muted text you add to a cell steps like the cell helpers': text-foreground/70 dark:text-muted-foreground. text-muted-foreground is 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 an aria-label or aria-labelledby on TableRoot. 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>.
ATStatus
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 / JAWSUNTESTED — cannot run on this machine
CarouselDataGrid

On this page

When to useWhen not to useInstallationUsageCurving your own contentExamplesDefaultVariantsSizesSortable columnsSelection + batch actionsRow statesRow intentsComparison matrixProgress cellsCrosstabRow groupsExpandable rowsSticky header, footer and columnCaption + scrollable regionPaginationMobile-fit tableEmpty & loadingLoad errorOn colorRTLAPI ReferenceTableRootTableContainerTableRowTableHeadTableColGroup / TableColTableCellTableCheckboxCellTableGroup / TableGroupHeaderTableExpandButton / TableExpandedRowTableBatchBarTableEmptyCell helpersTableSkeletonMigrationResponsiveAccessibility