The kit's richest component and the usage grammar around it. Kit source: packages/ui/src/table (layered: presentational Table, independent feature hooks, orchestrated DataTable). Evidence registers under docs/evidence/20260807-0230-speedway-design-language/ in this repo.
Audience: an agent (or person) building or reviewing a table.
Travels with: 07 (its toolbar), 04 (skeleton and the three empty states), 05 (the card it bleeds inside), 10 (the peek or modal a row opens), 09 (work surface), 02 (row actions). Request-shaped bundle: AP-10.
§A Principles and mental models#
1. Pick the right layer.
Table(Layer A): draws exactly the rows given. Sort is affordance-only. For short, capped lists (a job's max-30 runs, an org's members: "auto layout, not table-fixed: a members table is small").DataTable(Layer B): filter → search → sort → paginate pipeline (fixed order, so search never narrows just the current page), auto selection column, rowMenu, default toolbar, pager. "Turn features on by naming them infeatures; anything you leave out is off."- Raw
<table>: legitimate only for dense inline-EDITABLE form grids (field editors, sample previews), never for browsable lists.
2. Row click means exactly one thing per table.
Navigate (jobs list → job detail), or toggle expansion (when renderRowDetail exists, the name link becomes the way in), or quiet-pick (clickSelect tint). Never two. With checkbox selection outside the model, pass clickSelect={false}: "two selection states on one table diverge."
3. Tint priority is fixed.
?hl= highlight (bg-primary/10, scrolls into view once per key) > expanded (bg-primary/5) > click-picked (bg-primary/5, "open and picked read as one state", owner 2026-07-29) > hover. The click tint resets on page-flip, "the page number is the cheapest correct invalidation." ?hl=<id> is the app-wide "create lands you back on the list without losing the thing you just made" convention.
4. Columns encode meaning, via factories.
col.mono (ids/SKUs), col.number (right-aligned tabular), col.date, col.status (StatusPill from a mapper), col.actions. Column tip explains non-obvious columns. Cell helpers are explicit, "a value only becomes a link/date/code because you asked, never by accident." Rich cells: name + mono sub-line, FileChip + +N overflow, clickable counts (muted 0), slim progress bar + stage caption, consolidated ranked status badges.
5. Width discipline: scroll, never overlap, shed for peeks.
table-fixed + explicit minWidth makes narrow viewports scroll sideways ("owner: never overlap"). When a peek panel opens, shed the widest low-priority column instead of squeezing all. Expanded detail rows pin sticky left-0 to survive horizontal scroll.
6. Selection scope is explicit.
Kit selection is page-scoped ("select all" = visible page). Whole-filter selection ("select all in filter") lives OUTSIDE the model with hand-placed SelectAllCell/SelectionCell + clickSelect={false} (the review queue). Row keys must be globally unique across the table's real domain (the job-qualified composite key lesson: item ids were only unique per job).
7. One implementation, reused. Shared list surfaces get ONE component reused everywhere they appear (JobsTable across Jobs page + every module tab; one buildPartColumns for both parts tables; one AttrDefsTable rendering page and panel densities via a variant prop, "a peek is a placement change, not a re-authoring"). Stated in doc comments so the surfaces "can't drift."
8. Header and pager rulings.
Headers render as authored, Title Case, gray band (owner rounds 2026-07-10/16). Sort caret always trails the label. The sort button sizes to content so every header's affordance reads the same. Pager: windowed tokens keep fixed width at any depth. Cursor mode for keyset lists. Ctrl+Shift+arrows go to the pager inside an open modal first. copyCells puts the copy button beside the value, not at the column edge. It is opt-in per table rather than a default: a read-only data table should take it, editable grids should not (the value is already selectable there, and a copy button competes with the input). The sibling rule for generated content in overlays is 10 §A12, which draws the same data-plane line one layer up.
9. A sticky header needs an unbroken line to the thing that scrolls.
position: sticky resolves against the nearest scroll container, so any wrapper
between the header and the real scrollport captures it and pins it to a box that
never moves. Two consequences, both measured rather than reasoned. The table
takes overflow-x only when it declares minWidth or maxHeight, because
overflow on one axis makes an element the scrollport on both; a table that needs
to scroll sideways says so with minWidth. And the common idiom for clipping a
full-bleed table's corners, overflow-hidden on the wrapper, silently kills the
sticky header: use overflow-clip, which clips identically and does not create a
scroll container. With maxHeight the table owns its scrollport and top-0 is
right; without it the page is the scrollport, and since the shell insets its
content the header cancels that inset through --shell-inset.
§B Presets#
| Situation | Preset |
|---|---|
| Browsable list | DataTable + FilterBar toolbar + pager (25/50/100), full-bleed in card |
| Capped sub-list | plain Table, maxHeight cap, scrolled not paged |
| Editable grid | raw table-sm, native controls, cell-level skeletons |
| List + detail | expandable rows (one open at a time) or Eye-icon → modal |
| Bulk work surface | out-of-model filter-wide selection, clickSelect off, warn-tint in-flight rows |
| Post-create return | navigate with ?hl=<id> |
| In-modal table | density compact, max-h-[48dvh] scroll, column cap + footnote |
| Read-only data table | copyCells on; skip it on editable grids |
§C Use cases through Speedway#
- JobsTable: navigate-or-expand duality, ProgressCell only when expandable, StagePill otherwise. minWidth 1436/1536. Export toast warns on truncation.
- Parts tables:
?hl=, copyCells, active-row tint correlating with the open preview dialog, rowMenu only on the standalone page. - Review queue: 2000-row server window ("a 100-row window made the queue look mysteriously short", owner 2026-07-25), fixed px widths on every column, detailIndicator off (the Fix caret owns it).
- Log viewer: NOT a table. Scan-mode custom markup with level badges and client-side tail search (
run.tsx:512-537). Debug page: CodeBlocks + Tabs, no tables. - Audit log (
review/log.tsx:633-718): IS a table, a full DataTable + FilterBar, distinct from the scan-mode Log viewer above; toolbar detail in07 §C. - Admin: team/teams on DataTable with the house full-bleed trio. Diagnostic screens (scrape-requests, usage limits) on raw tables, flagged as the bounded rough tier.