Home

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.

PropWhat the consumer controls
sort on Table.HeadActive direction (or "none"); receives onSortToggle
selected on Table.RowWhether the row is highlighted
onPress on Table.RowRow activation; toolkit only paints the visual
hideDivider on Table.RowLast-row divider suppression
revealOnHover on Table.CellAction visibility (toolkit handles the transition; consumer decides the cell is hover-reveal)

2. What's not in the toolkit

PatternWhere it livesWhy outside
Title blockConsumerTypography and action layout differ per team
Filter panelConsumerFilters change per page; coupling them to <Table> adds config surface for no benefit
Item counterConsumerOne aria-live="polite" line
Bulk-selection barConsumerMounted above the table when selection is non-empty
PaginationSeparate pagination / stepper packagesDrop into Table.Footer; teams pick the right one
Sorting / filtering / paging logicConsumerHeadless contract
Empty / loading / error placeholdersConsumer renders inside Table.BodyIllustrations and CTAs differ per team

3. Stable height across pages (Table.Body minRows)

Paginated tables have three states that naturally render at different heights:

  1. Full pages — pageSize rows, divider stack between them
  2. Partial last page — fewer than pageSize rows
  3. 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.Row and there are fewer than minRows, 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

ComponentRole
TableContainer. role="table". Themed background, rounded corners.
Table.CaptionOptional description / count line.
Table.HeaderSticky role="rowgroup" for header row.
Table.Bodyrole="rowgroup" for body rows.
Table.FooterLayout slot for pagination.
Table.Rowrole="row". Used inside Header and Body.
Table.Headrole="columnheader" with aria-sort.
Table.Cellrole="cell".

Toolkit-specific props

ComponentPropTypeNotes
Table.BodyminRowsnumberStable height across paginated states
Table.RowselectedbooleanHighlights row, sets aria-selected
Table.RowonPress() => voidInteractive row (tabIndex + Enter/Space)
Table.RowhideDividerbooleanSuppresses bottom divider
Table.RowhoverablebooleanHover background paint
Table.Headsort"ascending" | "descending" | "none"Setting any value makes the head sortable
Table.HeadonSortToggle() => voidSort click handler
Head / Cellwidthnumber | stringFixed track width
Head / CellgrownumberFlex grow for non-fixed cells
Head / Cellalign"left" | "right" | "center"Content alignment
Head / Cellposition"default" | "left" | "right"Pins cell to row edge
Table.CellrevealOnHoverbooleanHides cell until row is hovered or focused