Table

Rows and columns of related records — orders, invoices, members — with sortable headers, selectable rows, a sticky header, and a responsive mode that stacks or scrolls without ever losing its table semantics. Static content; the interactive cell grid is DataGrid.

Basic usage

Install the package:

pnpm add @design-schema/react @design-schema/tokens

Import the stylesheet once for the app, then the component:

import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Table } from '@design-schema/react';

Examples

Open invoices

2 rows
InvoiceDue
INV-112 Sep100
INV-219 Sep200
import { Table, type TableColumn, type TableRow } from '@design-schema/react';

const INVOICE_COLUMNS: TableColumn[] = [
  { key: 'invoice', header: 'Invoice', isRowHeader: true },
  { key: 'due', header: 'Due' },
  { key: 'amount', header: 'Amount', align: 'end', sortable: true },
];

const INVOICES: TableRow[] = [
  { id: 'a', invoice: 'INV-1', due: '12 Sep', amount: 100 },
  { id: 'b', invoice: 'INV-2', due: '19 Sep', amount: 200 },
];

<Table caption="Open invoices" columns={INVOICE_COLUMNS} data={INVOICES} />

Props

19 rows
PropTypeDefaultDescriptionPlatforms
caption requiredstring—

What the table lists ("Open invoices"). Rendered as the caption and the accessible name; visually hidden with `hideCaption` when a Heading directly above already says it.

Accessibility: The <caption> (web) / aria-label on the container; never omitted.

web, lit, rn, swiftui
captionLevel2 | 3 | 42

Heading level of the caption in the page outline; its size is captionSize regardless.

web, lit, rn, swiftui
footercontent—

Content below the table: a row count, pagination, a total. Rendered in the `footer` part with the table's font: a string footer renders in Text (web: inside a `<p>`) with the `fontFamily`/`fontSize`/`lineHeight` bindings; other content brings its own typography (React Native has no cascade). Absent or `false` renders no footer part at all. Lit takes only slotted content in its `footer` slot — the string form has no Lit spelling, so slot a `<ds-text>` — and because `slotchange` only reports for a slot that is in the tree, the Lit wrapper always renders and takes the `footer` part only once something is assigned to it.

web, lit, rn, swiftui
hideCaptionbooleanfalse

Visually hide the caption; it remains the accessible name. On web and Lit the caption part is the composed Heading element itself, with no wrapper, so hiding it also sends `space.0` in place of `captionGap`: a hidden caption leaves no gap above the header, and it wins over a `captionGap` override, which does not reinstate one. The visually-hidden clip is what actually takes effect there — its own margin beats the forwarded gap — so the forward states the intent and the clip produces it. React Native has no way to put either the part hook or the hiding on a Heading, so there the caption part is a wrapper View that carries the testID and the clipping.

web, lit, rn, swiftui
columns requiredarray { key: string; header: string; abbr?: string; align?: "start" | "end" | "center"; sortable?: boolean; width?: "auto" | "min" | "fill"; isRowHeader?: boolean; hideBelow?: "prose" | "content"; render?: (row: Row) => ReactNode }[]—

Column definitions in display order. `header` is the visible heading; `align: end` for numbers; `sortable` adds the sort button; exactly one column may be `isRowHeader` (its cells become row headers, and it is the row's name when stacked); `hideBelow` drops a column below a layout width in `responsive: stack` only (scrolling tables keep every column), compared against the table's own measured width as `responsive` is, never the window; `width: min` shrinks to content without wrapping (web: `inline-size: 1%` plus nowrap), `fill` takes the remaining width (web: `inline-size: 100%`); `render` formats the cell (a Text, Link, Meter, or Button — never raw HTML).

web, lit, rn, swiftui
data requiredarray Row[] where Row = { id: string; [key: string]: unknown }—

The rows. `id` must be stable; it is what selection and keys use.

web, lit, rn, swiftui
sortobject { column: string; direction: "ascending" | "descending" }—

Controlled sort state. The table shows it; the caller sorts the data (so server-side sorting works the same way).

web, lit, rn, swiftui
defaultSortobject { column: string; direction: "ascending" | "descending" }—

Initial sort for uncontrolled use; the table then sorts `data` itself by the column value (localeCompare for strings, numeric otherwise).

web, lit, rn, swiftui
selectablenone | single | multiplenone

Adds a first column of Checkboxes (radio-like behavior for `single`) and a select-all in the header for `multiple`. Selection is row identity, not a visual state alone.

web, lit, rn, swiftui
selectedarray string[]—

Controlled selected row ids.

web, lit, rn, swiftui
defaultSelectedarray string[]—

Initially selected ids.

web, lit, rn, swiftui
responsivestack | scrollstack

Below `layout.maxWidth.prose`: `stack` turns each row into a block with the column header repeated as a label before each value (content tables — orders, people); `scroll` keeps the columns and scrolls horizontally inside a labelled region with the row-header column sticky (data tables where columns are the point). Roles are explicit on every element so both keep table semantics. "Below" compares the table's own measured width (a container query on web and Lit, onLayout on React Native) with the token, and means strictly less: at exactly `layout.maxWidth.prose` the table is still columnar, and `hideBelow` compares the same way; `scroll` keeps the columns at every width on every platform, phones included.

web, lit, rn, swiftui
stickyHeaderbooleantrue

The header row stays visible while the body scrolls (the page, or `maxHeight`). In `responsive: scroll` the scroll region is the sticky container, so the header sticks only with `maxHeight: viewport`; against the page scroll it has no effect there. React Native sticks the header only when the list scrolls itself — that is, with `maxHeight: viewport` — at every `responsive` value, since the page scroll is not the list's; the default being inert for a page-scrolled native table logs nothing, since the default is not the caller's doing. Stacked, what sticks is the whole header row that `responsive: stack` keeps: on web and Lit that means the stacked `thead` is `display: block` and only the row is the flex container, or the sticky band would be sized to its content and stop mid-table.

web, lit, rn, swiftui
maxHeightnone | viewportnone

`viewport` caps the table at the viewport height minus two `layout.gap.section` and scrolls a frame around the table (the scroll region itself in `responsive: scroll`), which is also the root of the header-shadow observer; `none` lets the page scroll. React Native measures the cap against the window height — it cannot know what chrome sits above it — and applies it to the list, which is the frame inside the horizontal ScrollView.

web, lit, rn, swiftui
densitycompact | comfortablecomfortable

Cell padding: layout.inset.sm or layout.inset.md.

web, lit, rn, swiftui
stripedbooleanfalse

Alternate row backgrounds: the even rows carry the tint, so the first data row keeps `surface`. Stacked blocks alternate the same way. Useful past about eight columns; borders are the default row separator.

web, lit, rn, swiftui
emptyMessagestring—

Shown in place of the body when `data` is empty. Defaults to `copy.empty`.

web, lit, rn, swiftui
loadingbooleanfalse

Data is being fetched: aria-busy is set on the table element (web and Lit: the `<table role="table">`; React Native: accessibilityState busy on the list) and `copy.loading` shows — with no rows, in the emptyState in place of `emptyMessage`; with rows, as Text `size: sm`, tone muted, in its own polite live region below the table and above the footer — the sort and selection announcements keep the visually-hidden region, so the two never overwrite each other. Existing rows stay visible while re-sorting.

web, lit, rn, swiftui
rowActionsfunction (row: Row) => ReactNode—

Renders a trailing actions cell: Buttons (ghost, sm, iconOnly with Tooltip) or a Menu. Kept out of `columns` so the header can be a visually-hidden "Actions".

web, lit, rn, swiftui

Events

3 rows
EventTypeDescriptionPlatforms
onSortChangeonSortChange / sort-change

Fired when a sortable header is activated, with `{ column, direction }` (cycling ascending → descending on the same column, ascending on a new one).

web, lit, rn, swiftui
onSelectionChangeonSelectionChange / selection-change

Fired with the new array of selected ids.

web, lit, rn, swiftui
onRowPressonRowPress / row-press

Fired when a row is activated, with its id. Only when the row has no other interactive content; the row header cell becomes a Button and the row is styled interactive. On web and Lit a pointer click anywhere on the row outside a control also fires it; keyboard and assistive technology use the Button. Lit cannot see whether anyone listens, so rows are interactive only when the host has the `pressable-rows` attribute — a reflected boolean property `pressableRows`. Prefer a Link in the row header for navigation.

web, lit, rn, swiftui

Accessibility

Decisions this component leaves to you

Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.

Required props that carry accessibility meaning: caption. Each is described in the props table above.

Implicit ARIA role: table. React Native renders role list.

Anatomy

containerscrollRegiontablecaptionheaderheaderRowcolumnHeadersortButtonbodyrowrowHeadercellselectCellselectAllCellstackedLabelemptyStatefooter

What generation guarantees

Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.

Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.

Arrow navigation — Arrow keys move between items; Home and End jump to the ends.

Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.

Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.

Target size — Interactive targets are at least 24×24 CSS pixels (WCAG 2.2 Target Size, minimum).

Selected state — Selection is exposed programmatically (`aria-selected` / `aria-checked`).

Live region — Changes are announced through a live region, without moving focus.

No hover-only content — Nothing is reachable by hover alone; keyboard focus reveals the same content.

Table keyboard support

3 rows
KeyAction
TabMoves through interactive content in reading order: select-all, then per row the checkbox, links, buttons and the actions cell. Cells themselves are not focusable — this is a table, not a grid.
Enter or SpaceOn a sort button, sorts; on a row checkbox, toggles; on a row header button, activates the row.
ArrowRight or ArrowLeftIn `responsive: scroll` below the breakpoint, the scroll region is focusable and arrows scroll it horizontally. (scroll region focused)

Contrast pairs

6 rows
ForegroundBackgroundLevel
color.foregroundcolor.backgroundAA
color.foreground.mutedcolor.backgroundAA
color.foregroundcolor.background.subtleAA
color.foreground.mutedcolor.background.subtleAA
color.linkcolor.background.subtleAA
color.control.selectedBackgroundcolor.background.subtleAA (non-text)

This component does not animate, so it has no reduced-motion behaviour to describe.