DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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 needUse
Editable cells / a spreadsheetA dedicated grid — no inline inputs in a table (route edits to a modal or page)
A record-per-row layout on phonesA list composition (Card / CellAvatar rows) — see Responsive below
Two columns of label + valueA description list, not a table
Tap-only counter rows, pickersThe 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" + 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. 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.

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

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 (Carbon: 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
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.

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

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

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

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.
  • 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>.
ATStatus
VoiceOver (macOS)Pending manual pass — badge stays beta
NVDA / JAWSUNTESTED — cannot run on this machine
SwitchTabs

On this page

PlaygroundInstallationUsageWhen to useResponsive doctrineCurving your own contentExamplesDefaultVariantsSizesSortable columnsSelection + batch actionsRow statesRow intentsComparison matrixProgress cellsCrosstabRow groupsExpandable rowsSticky header, footer and columnCaption + scrollable regionPaginationMobile-fit tableEmpty & loadingAPI ReferenceTableRootTableContainerTableRowTableHeadTableColGroup / TableColTableCellTableCheckboxCellTableGroup / TableGroupHeaderTableExpandButton / TableExpandedRowTableBatchBarTableEmptyCell helpersTableSkeletonMigrationAccessibility