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| Invoice | Due | |
|---|---|---|
| INV-1 | 12 Sep | 100 |
| INV-2 | 19 Sep | 200 |
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| Prop | Type | Default | Description |
|---|---|---|---|
| caption required | string | — | 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. |
| captionLevel | 2 | 3 | 4 | 2 | Heading level of the caption in the page outline; its size is captionSize regardless. |
| footer | content | — | 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. |
| hideCaption | boolean | false | 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. |
| columns required | array { 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). |
| data required | array Row[] where Row = { id: string; [key: string]: unknown } | — | The rows. `id` must be stable; it is what selection and keys use. |
| sort | object { 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). |
| defaultSort | object { 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). |
| selectable | none | single | multiple | none | 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. |
| selected | array string[] | — | Controlled selected row ids. |
| defaultSelected | array string[] | — | Initially selected ids. |
| responsive | stack | scroll | stack | 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. |
| stickyHeader | boolean | true | 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. |
| maxHeight | none | viewport | none | `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. |
| density | compact | comfortable | comfortable | Cell padding: layout.inset.sm or layout.inset.md. |
| striped | boolean | false | 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. |
| emptyMessage | string | — | Shown in place of the body when `data` is empty. Defaults to `copy.empty`. |
| loading | boolean | false | 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. |
| rowActions | function (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". |
Events
3 rows| Event | Type | Description |
|---|---|---|
| onSortChange | onSortChange / sort-change | Fired when a sortable header is activated, with `{ column, direction }` (cycling ascending → descending on the same column, ascending on a new one). |
| onSelectionChange | onSelectionChange / selection-change | Fired with the new array of selected ids. |
| onRowPress | onRowPress / 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. |
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
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.
This component does not animate, so it has no reduced-motion behaviour to describe.