A headless table for the toolkit
Presentation in the toolkit, logic outside — with useState, TanStack, or anything else.
The principle
The toolkit handles presentation. Teams handle logic.
The table package is headless by design — it controls how the table looks; everything else stays outside the toolkit. Teams bring their own logic using whichever fits their stack — plain useState, TanStack Table, React Aria, or anything else.
This keeps the bundle small, avoids locking teams into a specific data or state library, and lets the same primitives cover a wide range of table designs without forcing one product’s logic onto another’s UI.
Quick start
import { Table } from "@toolkit/table";
<Table>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head width={140}>Status</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map((row, i) => (
<Table.Row key={row.id} hideDivider={i === rows.length - 1}>
<Table.Cell>{row.name}</Table.Cell>
<Table.Cell width={140}>{row.status}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>Plain useState example
const [dir, setDir] = useState<"ascending" | "descending" | "none">("none");
const sorted = useMemo(() => sortBy(rows, "name", dir), [rows, dir]);
<Table.Head sort={dir} onSortToggle={() => setDir(next(dir))}>
Name
</Table.Head>TanStack Table example
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
table.getHeaderGroups().map((hg) => (
<Table.Row key={hg.id}>
{hg.headers.map((h) => (
<Table.Head
key={h.id}
sort={h.column.getIsSorted() || "none"}
onSortToggle={h.column.getToggleSortingHandler()}
>
{flexRender(h.column.columnDef.header, h.getContext())}
</Table.Head>
))}
</Table.Row>
));Design rationale
1. Headless — state lives outside
Every stateful prop is fully controlled by the consumer.
| Prop | What the consumer controls |
|---|---|
sort on Table.Head | Active direction (or "none"); receives onSortToggle |
selected on Table.Row | Whether the row is highlighted |
onPress on Table.Row | Row activation; toolkit only paints the visual |
hideDivider on Table.Row | Last-row divider suppression |
revealOnHover on Table.Cell | Action visibility (toolkit handles the transition; consumer decides the cell is hover-reveal) |
2. What's not in the toolkit
| Pattern | Where it lives | Why outside |
|---|---|---|
| Title block | Consumer | Typography and action layout differ per team |
| Filter panel | Consumer | Filters change per page; coupling them to <Table> adds config surface for no benefit |
| Item counter | Consumer | One aria-live="polite" line |
| Bulk-selection bar | Consumer | Mounted above the table when selection is non-empty |
| Pagination | Separate pagination / stepper packages | Drop into Table.Footer; teams pick the right one |
| Sorting / filtering / paging logic | Consumer | Headless contract |
| Empty / loading / error placeholders | Consumer renders inside Table.Body | Illustrations and CTAs differ per team |
3. Stable height across pages (Table.Body minRows)
Paginated tables have three states that naturally render at different heights:
- Full pages —
pageSizerows, divider stack between them - Partial last page — fewer than
pageSizerows - No-results state — a single empty-state block
Pass minRows={pageSize} to <Table.Body> and the body locks itself to the full page height for all three:
<Table.Body minRows={pageSize}>
{rows.length === 0 ? (
<Box style={{ flex: 1 /* centered no-results message */ }}>
No results
</Box>
) : (
rows.map((row, i) => (
<Table.Row key={row.id} hideDivider={i === rows.length - 1}>
{/* cells */}
</Table.Row>
))
)}
</Table.Body>Behavior:
- Body reserves a min-height of
minRows × rowHeight + (minRows - 1) × dividerHeight. - When all children are
Table.Rowand there are fewer thanminRows, invisible placeholder slots are appended. - When the body holds a non-row child (e.g. an empty-state panel), no placeholders are appended — only min-height is enforced, so the panel can stretch via
flex: 1.
API reference
Components
| Component | Role |
|---|---|
| Table | Container. role="table". Themed background, rounded corners. |
| Table.Caption | Optional description / count line. |
| Table.Header | Sticky role="rowgroup" for header row. |
| Table.Body | role="rowgroup" for body rows. |
| Table.Footer | Layout slot for pagination. |
| Table.Row | role="row". Used inside Header and Body. |
| Table.Head | role="columnheader" with aria-sort. |
| Table.Cell | role="cell". |
Toolkit-specific props
| Component | Prop | Type | Notes |
|---|---|---|---|
| Table.Body | minRows | number | Stable height across paginated states |
| Table.Row | selected | boolean | Highlights row, sets aria-selected |
| Table.Row | onPress | () => void | Interactive row (tabIndex + Enter/Space) |
| Table.Row | hideDivider | boolean | Suppresses bottom divider |
| Table.Row | hoverable | boolean | Hover background paint |
| Table.Head | sort | "ascending" | "descending" | "none" | Setting any value makes the head sortable |
| Table.Head | onSortToggle | () => void | Sort click handler |
| Head / Cell | width | number | string | Fixed track width |
| Head / Cell | grow | number | Flex grow for non-fixed cells |
| Head / Cell | align | "left" | "right" | "center" | Content alignment |
| Head / Cell | position | "default" | "left" | "right" | Pins cell to row edge |
| Table.Cell | revealOnHover | boolean | Hides cell until row is hovered or focused |