Table
A dense, sortable grid for scannable record lists — meters, devices, invoices, leads.
| Device | Type | Status | Power |
|---|---|---|---|
| Inverter A1 | Inverter | 4.2 kW | |
| Wallbox Garage | Charger | 11.0 kW | |
| Battery Cellar | Storage | 2.6 kW | |
| Heat pump | Heating | 0.0 kW |
Usage
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableStatus,
} from '@epilot/spark-ui/table'<Table>
<TableHeader>
<TableRow>
<TableHead>Device</TableHead>
<TableHead align="end">Power</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Inverter A1</TableCell>
<TableCell align="end">4.2 kW</TableCell>
</TableRow>
</TableBody>
</Table>Columns are JSX, not a config array: one TableHead per column in the header row, one TableCell per column in each body row — the same compound style as List. Value formatting stays app-side.
Use Table for many columns the user scans as a grid. Reach for List when each record carries one or two fields and reads as a row of cards, which is the better shape on a phone. Table ships no pagination: compose Buttons in a CardFooter below it.
Examples
Sortable columns
Sorting is controlled only. TableHead sortKey turns a header into a sort button; clicking it emits the next step of the cycle (ascending → descending → unsorted) through onSortChange. The table never reorders rows — the comparator is app knowledge (text, dates, currency, relation counts), and you hold the state anyway.
A sortable header needs both sort and onSortChange on the root: without them the button renders and clicking it does nothing.
| Battery Cellar | Storage | 2.6 kW |
| Heat pump | Heating | 0.0 kW |
| Inverter A1 | Inverter | 4.2 kW |
| Wallbox Garage | Charger | 11.0 kW |
Density
Row height follows the theme's spacing knob — there is no density prop. Rows step 2 / 2.5 / 3rem (times the scaling factor) with dense / default / roomy. Set it on any ancestor Theme and every table under it steps together; a nested Theme wins over an outer one, because the height reads the same --spark-layout-gap variable the other collection primitives read. On coarse pointers an interactive row is floored at 44px, so it clears the touch target even at the smallest scaling.
Table is the documented exception to "spacing never resizes a component's internals": a row is the between-space of a collection, like a List item, not a control's internals.
| Device | Type | Power |
|---|---|---|
| Inverter A1 | Inverter | 4.2 kW |
| Wallbox Garage | Charger | 11.0 kW |
| Battery Cellar | Storage | 2.6 kW |
| Device | Type | Power |
|---|---|---|
| Inverter A1 | Inverter | 4.2 kW |
| Wallbox Garage | Charger | 11.0 kW |
| Battery Cellar | Storage | 2.6 kW |
| Device | Type | Power |
|---|---|---|
| Inverter A1 | Inverter | 4.2 kW |
| Wallbox Garage | Charger | 11.0 kW |
| Battery Cellar | Storage | 2.6 kW |
Sticky header
stickyHeader pins the header row while the body scrolls. Bound the height yourself through containerProps.className; an aria-label there also turns the scroll box into a focusable, named region so keyboard users can reach it.
| Day | Produced | Fed in |
|---|---|---|
| 01 Jun 2026 | 18.4 kW | 6.1 kW |
| 02 Jun 2026 | 17.7 kW | 5.8 kW |
| 03 Jun 2026 | 17.0 kW | 5.5 kW |
| 04 Jun 2026 | 16.3 kW | 5.2 kW |
| 05 Jun 2026 | 15.6 kW | 4.9 kW |
| 06 Jun 2026 | 14.9 kW | 4.6 kW |
| 07 Jun 2026 | 14.2 kW | 4.3 kW |
| 08 Jun 2026 | 13.5 kW | 4.0 kW |
| 09 Jun 2026 | 12.8 kW | 3.7 kW |
| 10 Jun 2026 | 12.1 kW | 3.4 kW |
| 11 Jun 2026 | 11.4 kW | 3.1 kW |
| 12 Jun 2026 | 10.7 kW | 2.8 kW |
Interactive rows
An onClick on TableRow makes that row interactive: pointer, Enter and Space all activate it, and it takes a hover tint plus a focus outline. selected keeps the tint on and emits data-selected. Rows stay <tr> elements, so put a link or a button in a cell if the target has to be a real navigation.
| Device | Type | Power |
|---|---|---|
| Inverter A1 | Inverter | 4.2 kW |
| Wallbox Garage | Charger | 11.0 kW |
| Battery Cellar | Storage | 2.6 kW |
| Heat pump | Heating | 0.0 kW |
Loading, empty and error
Loading is N rows with skeleton — the cells render placeholder bars. Empty and error states are a TableStatus row spanning every column, wrapping an EmptyState or an error Callout. No copy is baked in.
An element child keeps its own padding and text alignment; bare text gets the row's padding and is centred.
| Device | Power |
|---|
| Device | Power |
|---|---|
No devicesConnected devices appear here once the gateway reports them. | |
| Device | Power |
|---|---|
Devices could not be loaded. Try again in a moment. | |
API Reference
Table
| Prop | Type | Default | Description |
|---|---|---|---|
stickyHeader | boolean | false | Pins header cells to the top of the scroll container with an opaque fill. |
sort | TableSort | null | null | The active { key, direction }, or null for unsorted. Controlled. |
onSortChange | (sort: TableSort | null) => void | — | Emitted with the next cycle step when a sortable header is clicked. |
containerProps | ComponentProps<'div'> | — | Spread onto the scroll wrapper. An aria-label makes it a focusable role="region". |
Renders a <div> scroll container around a native <table>. The table is border-separate, so every rule lives on the cells: under border-collapse Chromium drops the border of a sticky <th>.
TableRow
| Prop | Type | Default | Description |
|---|---|---|---|
selected | boolean | false | Persistent accent tint; also emits data-selected. |
skeleton | boolean | false | Placeholder row: cells render Skeleton bars, the row is aria-hidden and inert. |
onClick is what makes a row interactive — there is no interactive prop.
TableHead
| Prop | Type | Default | Description |
|---|---|---|---|
align | 'start' | 'end' | 'start' | end right-aligns; pair it with the same align on the column's cells. |
sortKey | string | — | Wraps the header in a sort <button> with an indicator and keeps aria-sort in sync. |
On a right-aligned sortable header the indicator moves inboard, so the column keeps one clean alignment edge.
TableCell
| Prop | Type | Default | Description |
|---|---|---|---|
align | 'start' | 'end' | 'start' | end right-aligns and sets tabular-nums for numeric columns. |
TableStatus
| Prop | Type | Default | Description |
|---|---|---|---|
colSpan | number | — | Required. Renders a <tr><td> spanning that many columns. |
The cell pads a child element just enough to clear the header rule and otherwise leaves it its own vertical rhythm and alignment (EmptyState centres itself, Callout stays left-aligned); bare text is padded and centred for you.
Types
type SortDirection = 'asc' | 'desc'
interface TableSort {
key: string
direction: SortDirection
}Accessibility
- Native
<table>semantics: rows, columns and headers are announced by the browser, so no ARIA grid roles are added. - A sortable header is a real
<button type="button">inside the<th>, and the<th>carriesaria-sort="ascending" | "descending" | "none". A non-sortable header gets noaria-sort; pass your own if you sort outside the component. - Interactive rows keep their row semantics (no
role="link"on a<tr>, which would destroy them). They gettabIndex={0}plus Enter/Space, and Space is prevented from scrolling the page. A key pressed inside a nested control — a button, link or input in a cell — stays with that control and never activates the row. skeletonrows arearia-hiddenand never focusable or clickable, so a loading placeholder is never announced or activated.- Passing
aria-labelthroughcontainerPropsgives the scroll containerrole="region"andtabIndex={0}, which is what lets a keyboard user scroll a wide table (WCAG 2.1.1). selectedis visual only —aria-selectedis not valid on a plain<table>row, so selection that has to be announced belongs in a cell (a checkbox, oraria-current).- The only height floors are the sort button's 24px, and, on coarse pointers (WCAG 2.5.5), 44px on interactive rows via
touch-target-table-row. A non-interactive row has no floor, androomyitself can sit below 44px under sub-100% scaling (43.2px at 90%). Heights are set throughheight, notmin-height, which does not apply to table boxes.