Table component
Table is a config-driven data table: pass `columns` and `rows` and get sorting, row selection, row expansion, a sticky header, and built-in loading and empty states. On narrow viewports it falls back to stacked cards.
Playground
Change a prop on the right and the preview updates. Switch to Code to copy what you see.
Recipes
Copy-paste patterns for the ways Table actually gets used — each one a real, self-contained example.
Basic table
Pass columns and rows and you get a real, semantic <table> with a styled header — plus a stacked-card fallback on narrow viewports.
<Table
aria-label="Team members"
columns={[
{ key: "name", header: "Name" },
{ key: "email", header: "Email" },
{ key: "role", header: "Role" },
]}
rows={rows}
getRowKey={(r) => r.id}
/>Sortable columns
Mark a column sortable and its header becomes a sort button with aria-sort. With no onSortChange, Table sorts client-side for you.
<Table
aria-label="Team members"
columns={[
{ key: "name", header: "Name", sortable: true },
{ key: "role", header: "Role" },
{ key: "mrr", header: "MRR", align: "end", sortable: true },
]}
rows={rows}
getRowKey={(r) => r.id}
defaultSortDescriptor={{ column: "mrr", direction: "descending" }}
/>Row selection
selectionMode="multiple" adds labelled, keyboard-operable checkboxes; keep the selection in your own state via selectedKeys/onSelectionChange.
const [selected, setSelected] = useState(new Set());
<Table
aria-label="Team members"
columns={columns}
rows={rows}
getRowKey={(r) => r.id}
selectionMode="multiple"
selectedKeys={selected}
onSelectionChange={(keys) => setSelected(keys)}
/>Custom cell rendering
A column's render function returns any node for that cell — a status Chip, a formatted number, an avatar — while sorting still works off the raw row value.
<Table
aria-label="Invoices"
columns={[
{ key: "id", header: "Invoice" },
{
key: "status",
header: "Status",
render: (row) => (
<Chip
variant="soft"
color={row.status === "Paid" ? "success" : "warning"}
>
{row.status}
</Chip>
),
},
{ key: "amount", header: "Amount", align: "end" },
]}
rows={rows}
getRowKey={(r) => r.id}
/>Loading state
isLoading swaps the body for skeleton rows (loadingRowCount controls how many), so the layout never jumps when the real data arrives.
<Table
aria-label="Team members"
columns={columns}
rows={[]}
getRowKey={(r) => r.id}
isLoading
loadingRowCount={4}
/>Example
<Table
columns={[
{ key: "name", header: "Name", sortable: true },
{ key: "plan", header: "Plan" },
{ key: "mrr", header: "MRR", align: "end", sortable: true },
]}
rows={customers}
selectable
onSelectionChange={setSelected}
/>Props
The full public API for Table. Every prop is stable and intentionally typed.
Data
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | TableColumn<Row>[] | — | { key, header, align?, sortable?, sortValue?, render?, width?, headerClassName?, cellClassName? }. |
| rows | Row[] | — | The data. |
| getRowKey | (row, index) => string | number | — | Stable key — use an id field, not the index. |
| aria-label | string | — | Required. |
columns
TableColumn<Row>[]
default: —
{ key, header, align?, sortable?, sortValue?, render?, width?, headerClassName?, cellClassName? }.
rows
Row[]
default: —
The data.
getRowKey
(row, index) => string | number
default: —
Stable key — use an id field, not the index.
aria-label
string
default: —
Required.
Sorting
| Prop | Type | Default | Description |
|---|---|---|---|
| sortDescriptor / defaultSortDescriptor | { column, direction } | null | null | Current / initial sort. |
| onSortChange | (descriptor) => void | — | Present → server-side sort; omit for client-side. |
sortDescriptor / defaultSortDescriptor
{ column, direction } | null
default: null
Current / initial sort.
onSortChange
(descriptor) => void
default: —
Present → server-side sort; omit for client-side.
Selection
| Prop | Type | Default | Description |
|---|---|---|---|
| selectionMode | "none" | "single" | "multiple" | none | Row selection. |
| selectedKeys / defaultSelectedKeys / onSelectionChange | Iterable<Key> / … | — | Controlled / uncontrolled selection. |
| isRowSelectable | (row) => boolean | — | Disable selection per row. |
selectionMode
"none" | "single" | "multiple"
default: none
Row selection.
selectedKeys / defaultSelectedKeys / onSelectionChange
Iterable<Key> / …
default: —
Controlled / uncontrolled selection.
isRowSelectable
(row) => boolean
default: —
Disable selection per row.
Expansion
| Prop | Type | Default | Description |
|---|---|---|---|
| renderExpandedRow | (row) => ReactNode | — | Adds the chevron expand-toggle column. |
| expandedKeys / defaultExpandedKeys / onExpandedChange | Iterable<Key> / … | — | Controlled expansion. |
renderExpandedRow
(row) => ReactNode
default: —
Adds the chevron expand-toggle column.
expandedKeys / defaultExpandedKeys / onExpandedChange
Iterable<Key> / …
default: —
Controlled expansion.
Behavior & states
| Prop | Type | Default | Description |
|---|---|---|---|
| isLoading / loadingRowCount | boolean / number | false / 5 | Skeleton rows while loading. |
| emptyContent | ReactNode | <EmptyState title="No data" /> | Shown when rows is empty. |
| onRowAction | (key, row) => void | — | Makes rows clickable (click / Enter / Space). |
isLoading / loadingRowCount
boolean / number
default: false / 5
Skeleton rows while loading.
emptyContent
ReactNode
default: <EmptyState title="No data" />
Shown when rows is empty.
onRowAction
(key, row) => void
default: —
Makes rows clickable (click / Enter / Space).
Layout & positioning
| Prop | Type | Default | Description |
|---|---|---|---|
| stickyHeader / maxHeight | boolean / number | string | false / — | Sticky header + vertical scroll container. |
| isCompact / isStriped / removeWrapper | boolean | false | Density and framing. |
| topContent / bottomContent | ReactNode | — | Toolbar / pagination slots. |
stickyHeader / maxHeight
boolean / number | string
default: false / —
Sticky header + vertical scroll container.
isCompact / isStriped / removeWrapper
boolean
default: false
Density and framing.
topContent / bottomContent
ReactNode
default: —
Toolbar / pagination slots.
Styling
| Prop | Type | Default | Description |
|---|---|---|---|
| classNames | TableClassNames | — | Per-slot class overrides. |
classNames
TableClassNames
default: —
Per-slot class overrides.
Accessibility
- Renders a real `<table>` with `<th scope>` headers; sortable headers are buttons with `aria-sort`.
- The select-all and per-row checkboxes are labelled and keyboard operable.
- Loading and empty states replace the body with a labelled status region, not a blank table.