Table
Table is the catalog's first TanStack-backed component: its state engine is
@tanstack/react-table, not a Spar primitive
(Spar intentionally has no table). The public API is props-first — a common
table is a single <Table data columns getRowId /> call — with a column-def +
slot escape hatch for customization. getRowId is mandatory: it is the single
identity source row selection keys on.
Playground
A single <Table> call with sortable headers, a Badge cell, a checkbox column
filter, multi-row selection, striping, and client pagination.
Basic
The minimum is data, columns, and getRowId. accessor is a property key,
a dot-path ('user.city'), or a (row) => value function.
Custom cells & actions
A column's cell is a React render-prop (never an HTML string) — put a
Badge, Button, icon, or anything else inside. Table still owns the <td>
container (padding, alignment, sticky, a11y); cell only supplies the content.
Column filtering
A column's filter is two-tier: declarative presets for the common cases,
and a render escape hatch for everything else. Either way Table owns the
plumbing — the header trigger, the Popover (a real portal), the active-state
dot, and the value wiring to TanStack.
Presets
For the common controls, pass a preset — a bare string for text, or an object
with type (+ options for the choice controls):
type | Control | Filter value | Match |
|---|---|---|---|
text | Input | string | substring |
select | Select (single) | string | equality |
radio | Radio list | string | equality |
checkbox | Checkbox list | string[] | membership |
multi-select | Checkbox list¹ | string[] | membership |
{ id: 'name', header: 'Name', accessor: 'name', filter: 'text' }
{ id: 'role', header: 'Role', accessor: 'role',
filter: { type: 'checkbox', options: [
{ label: 'Admin', value: 'admin' },
{ label: 'User', value: 'user' },
] } }
¹ Spar's Select has no multi mode yet, so multi-select currently renders
the same Checkbox list as checkbox.
Custom filters (escape hatch)
When no preset fits — a number range, a date range, a combobox, async-loaded
options — pass render instead. It receives
{ value, setValue, clear, column, close } and returns any control; Table still
owns the surrounding popover. value is whatever shape your control writes, so
pair it with a filterFn (client mode) and, if the default non-empty heuristic
is wrong, an isActive predicate for the active dot.
Why both tiers?
The legacy takeoff-ui filter was a closed type enum, which could never
cover every column's needs, so customization ended up blocked on a library
change. Every mature table (Mantine/Material React Table, AG Grid, MUI X)
instead layers a short preset under a render escape hatch. Presets keep
the 90% case one-line; render (the same inversion as cell /
expansion.render) leaves the rest fully open.
Expandable rows
Expansion has two mutually exclusive shapes. The first is a detail
panel: pass expansion.render to render a disclosure row beneath each row.
The second is tree data (getSubRows), covered below — supplying both keeps
tree mode and suppresses the detail panel.
Tree data (sub-rows)
For hierarchical data, pass getSubRows to read each row's children and an
expansion config (without render) to make the expand toggle reveal those
flattened sub-rows instead of a detail panel. The toggle appears only on rows
that actually have children; row.depth is available inside a cell to indent
nested rows. Here expansion={{ defaultValue: true }} expands everything on
mount.
Sticky columns & header
Pin columns to the left or right edge with sticky: 'left' | 'right' on a
column def. Add stickyHeader to also pin the header row during vertical
scroll. Pass maxHeight to the viewport via slotProps.tableViewport to enable
vertical scroll — sticky header only activates when the viewport itself scrolls,
not a parent container. The example below constrains the wrapper to 720px wide
and the viewport to 280px tall so both axes scroll.
Density
size scales the cell padding — 'xsmall', 'small', or 'base' (the
default) — and surfaces as data-size for recipes to scope against.
Empty state
When data is empty, Table renders a single full-width cell. The default copy
is No data; pass emptyState to supply your own node. While loading is true
the empty copy is suppressed so it does not flash mid-fetch.
Loading
loading overlays a Spar Spinner (role="status"), marks the table
aria-busy, and sets data-loading for theming. Existing rows stay visible
beneath the overlay so the table does not collapse during a refetch.
Server (manual) data
In manual mode Table processes nothing in-memory. It maps to TanStack's
manualSorting / manualFiltering / manualPagination and emits one bundled
onDataRequest derived from the current sorting, filters, and pagination —
fetch the page yourself and feed the result back through data. pagination
must carry rowCount so the page count and Next/Last controls can be computed.
<Table
data={page.rows}
columns={columns}
getRowId={row => row.id}
manual
sorting={{ value: sorting, onChange: setSorting }}
filtering={{ value: filters, onChange: setFilters }}
pagination={{
pageSize,
pageIndex,
rowCount: page.total,
onChange: setPagination,
}}
onDataRequest={({ pagination, sorting, filters }) =>
fetchPage({ pagination, sorting, filters })
}
loading={isFetching}
/>
Exporting data
Table ships no export engine — it exposes the current filtered + sorted rows
as a plain value projection via getExportRows(tableRef.current). Formatting
and file download (CSV / Excel / PDF) stay on your side, keeping those heavy
dependencies out of the bundle.
import { Table, getExportRows } from '@takeoff-ui/react-spar';
const tableRef = useRef(null);
<Table
data={rows}
columns={columns}
getRowId={r => r.id}
tableRef={tableRef}
/>;
// later — e.g. a "Download CSV" button:
const rowsToExport = getExportRows(tableRef.current); // Array<Record<columnId, value>>
Accessibility
- Renders a native
<table>/<thead>/<tbody>/<th scope="col">/<td>, so screen-reader table navigation, header association, and row/column counts come for free. - Sortable headers are real
<button>s (Enter / Space toggle sorting) and the<th>carriesaria-sort(ascending|descending|none). - Row selection composes Spar
Checkbox(multiple) orRadio(single) with accessible names; the select-all header checkbox is multiple-mode only. - Pagination is a labelled
navigationregion; the page-size control is a SparSelectwith an accessible name, and the nav buttons carryaria-labels. - The loading overlay surfaces the Spar
Spinner(role="status") and the table is markedaria-busywhileloading.
| Key | Behavior |
|---|---|
| Tab | Move between interactive controls in the table. |
| Enter / Space | Toggle sorting on a focused sortable header. |
API Reference
Table
See TanStack Table docs for primitive behavior.
Props
| Name | Type | Default | Description |
|---|---|---|---|
| data | TData[] | - | Row data. In manual mode this is the current server page. |
| columns | TableColumnDef<TData>[] | - | Column definitions. See TableColumnDef. |
| getRowId | (row: TData, index: number) => string | - | Stable row identity (single source of truth). |
| size | TableSize | 'base' | Density scale. |
| striped | boolean | false | Zebra-stripe rows → data-striped. |
| bordered | boolean | false | Cell borders → data-bordered. |
| stickyHeader | boolean | false | Pin the header row during vertical scroll → data-sticky-header. |
| manual | boolean | false | Server mode. When true, Table processes nothing in-memory — it maps to TanStack's manualSorting/manualFiltering/manualPagination and emits a single bundled onDataRequest (RFC §3.3). |
| selection | TableSelectionConfig | - | Row selection (single/multiple + select-all). Composes Spar Checkbox/Radio. |
| sorting | TableSortingConfig | - | Sorting (multi-sort opt-in). |
| filtering | TableFilteringConfig | - | Column filtering. Filter UIs render in a Spar Popover. |
| expansion | TableExpansionConfig<TData> | - | Expandable rows with a render-prop body. |
| pagination | boolean | TablePaginationConfig | - | Pagination. true enables it with defaults; an object configures it. |
| loading | boolean | false | Loading state → data-loading + a loading overlay. |
| emptyState | React.ReactNode | - | Content rendered when there are no rows. |
| getSubRows | (row: TData) => TData[] | - | Sub-row reader for tree data (feeds TanStack getSubRows). Pair it with expansion (sans render) so expanding a row reveals its flattened sub-rows. When getSubRows is omitted, expansion falls back to the detail-panel mode driven by expansion.render. Supplying both keeps tree mode and suppresses the detail panel. |
| tableRef | Ref<TanStackTable<TData> | null> | - | Escape hatch for the rare imperative need — receives the TanStack table instance (RFC §2.3: controlled props + an optional instance ref, never an imperative @Method surface). Also the access point for getExportRows(). |
| classNames | Partial<Record<TableSlot, string>> | - | Per-slot class name overrides. |
| slotProps | Partial<Record<TableSlot, React.HTMLAttributes<HTMLElement>>> | - | Per-slot HTML attribute overrides. |
| className | string | - | Appends custom classes to the root container. |
Events
| Name | Type | Default | Description |
|---|---|---|---|
| onDataRequest | (request: TableDataRequest) => void | - | Bundled server data-request callback (manual mode). See TableDataRequest. |
Data attributes
| Attribute | Applied when | Purpose |
|---|---|---|
| data-slot="root" | Always | Stable selector for the root container. |
| data-slot="table-viewport" | Always | The table-only horizontal/vertical scroll container; pagination remains outside it. |
| data-size | Always | Reflects the resolved size (density) so recipes can scope cell padding. |
| data-striped | When striped is true. | Enables zebra striping on body rows. |
| data-bordered | When bordered is true. | Adds vertical separators between columns. |
| data-sticky-header | When stickyHeader is true. | Pins the header row during vertical scroll. |
| data-scrolled | On the table viewport while scrollTop > 0. | Adds sticky-header elevation only after vertical scrolling begins. |
| data-loading | When loading is true. | Shows the loading overlay; the table is also marked aria-busy. |
| data-align | On every header/body cell with an align (or column meta.headerAlign). | Drives cell text-align (start | center | end). |
| data-sticky | On cells of a column with sticky: "left" | "right". | Pins the column; per-edge offset + z-index are applied inline. |
| aria-sort | On sortable header cells. | A11y sort state (ascending | descending | none); the sort chevron mirrors it via data-direction. |
| data-selected | On a selected body row. | Theme hook for the selected-row background. |
Type Definitions
| Name | Definition |
|---|---|
| TableSize | 'xsmall' | 'small' | 'base' |
| TableSelectionConfig | { mode: 'single' | 'multiple' } |
| TableSortingConfig | { multi?: boolean } |
| TableFilteringConfig | { value?: T; defaultValue?: T; onChange?: (value: T) => void } |
| TablePaginationConfig | { pageSize?: number; pageIndex?: number; pageSizeOptions?: number[]; rowCount?: number; onChange?: (pagination: PaginationState) => void } |
| TableSlot | | 'root' | 'tableViewport' | 'table' | 'header' | 'headerRow' | 'headerCell' | 'headerContent' | 'sortTrigger' | 'sortIcon' | 'body' | 'row' | 'cell' | 'selectionCell' | 'expandCell' | 'expandButton' | 'expandedRow' | 'filterButton' | 'filterPanel' | 'pagination' | 'paginationInfo' | 'paginationNav' | 'paginationActions' | 'paginationSize' | 'paginationGoToPage' | 'empty' | 'loading' |
| TableDataRequest | { pagination: PaginationState; sorting: SortingState; filters: ColumnFiltersState } |