Table
A three-rule data table in a Scroll Area: a heavy frame above and below, a header rule, hairline rows and no verticals, stripes or tints.
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFoot,
TableHead,
TableHeaderCell,
TableRow,
} from '@fairgarden-private/design/components/Table'
Column headers are caps labels with their units ("Length (km)"); row headers are TableHeaderCells in the body. Numbers are right-aligned lining tabular figures with fixed decimals per column. Text cells wrap and are never truncated, and an empty cell shows "—", defined in the notes. The caption block ("Table 1", a title and a scope note) sits above the table, and the notes block sits below it.
Records, sorting and totals
Records, sorting and totals
strategy="stack" is the default for records: below 768 px of the table's width, each row becomes a block, with its row header over a dotted leader list of header → value. A header with sort and onSort becomes a sort button that keeps the caps, shows ▲ or ▼ after the label and carries aria-sort. TableFoot opens with the double hairline total rule.
Source: Trail stewardship survey, spring 2026.
— No measurement yet.
'use client'
import * as React from 'react'
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFoot,
TableHead,
TableHeaderCell,
TableRow,
type TableSort,
} from '@fairgarden-private/design/components/Table'
interface Trail {
name: string
region: string
length: number
gain: number | null
}
const trails: readonly Trail[] = [
{ name: 'Cedar Loop', region: 'North Ridge', length: 4.2, gain: 120 },
{ name: 'Heron Marsh Boardwalk', region: 'Lowlands', length: 1.6, gain: 0 },
{ name: 'Old Quarry Climb', region: 'North Ridge', length: 7.9, gain: 410 },
{ name: 'Meadow Link', region: 'Valley', length: 3.1, gain: null },
{ name: 'Prairie Overlook', region: 'Valley', length: 5.4, gain: 180 },
]
const number = new Intl.NumberFormat('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })
type Column = 'name' | 'length'
/** Records stack below 768 px of the table; the name and length headers sort. */
export function TableRecords() {
const [sort, setSort] = React.useState<{ column: Column; direction: TableSort }>({
column: 'name',
direction: 'ascending',
})
const rows = [...trails].sort((a, b) => {
const order =
sort.column === 'name' ? a.name.localeCompare(b.name) : a.length - b.length
return sort.direction === 'descending' ? -order : order
})
const sortBy = (column: Column) => () =>
setSort((current) => ({
column,
direction:
current.column === column && current.direction === 'ascending'
? 'descending'
: 'ascending',
}))
const stateOf = (column: Column): TableSort =>
sort.column === column ? sort.direction : 'none'
const total = trails.reduce((sum, trail) => sum + trail.length, 0)
return (
<Table
notes={
<>
<p>Source: Trail stewardship survey, spring 2026.</p>
<p>— No measurement yet.</p>
</>
}
>
<TableCaption label="Table 1" note="Lengths in kilometres; elevation gain in metres.">
Trails by length
</TableCaption>
<TableHead>
<TableRow>
<TableHeaderCell sort={stateOf('name')} onSort={sortBy('name')}>
Trail
</TableHeaderCell>
<TableHeaderCell>Region</TableHeaderCell>
<TableHeaderCell numeric sort={stateOf('length')} onSort={sortBy('length')}>
Length (km)
</TableHeaderCell>
<TableHeaderCell numeric>Gain (m)</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((trail) => (
<TableRow key={trail.name}>
<TableHeaderCell>{trail.name}</TableHeaderCell>
<TableCell>{trail.region}</TableCell>
<TableCell numeric>{number.format(trail.length)}</TableCell>
<TableCell numeric>{trail.gain == null ? '—' : trail.gain}</TableCell>
</TableRow>
))}
</TableBody>
<TableFoot>
<TableRow>
<TableHeaderCell colSpan={2}>Total</TableHeaderCell>
<TableCell numeric>{number.format(total)}</TableCell>
<TableCell numeric>—</TableCell>
</TableRow>
</TableFoot>
</Table>
)
}
The five-row cadence
The five-row cadence
A body of more than 10 rows swaps the hairlines for dotted rules, with a solid rule after every fifth row, so the eye tracks in fives and the rhythm prints in black. selected (or a checked row Checkbox, or the row that is a link's :target) draws the 3 px row bar. inactive mutes a row, so pair it with a word. strategy="fit" keeps a table of up to four short columns as a table at every width.
'use client'
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
} from '@fairgarden-private/design/components/Table'
const counts = [
['January', 412, 38],
['February', 388, 35],
['March', 540, 44],
['April', 716, 61],
['May', 902, 73],
['June', 874, 70],
['July', 655, 58],
['August', 610, 54],
['September', 781, 66],
['October', 693, 59],
['November', 470, 41],
['December', 395, 36],
] as const
const number = new Intl.NumberFormat('en-US')
/**
* Twelve body rows switch to the five-row cadence: dotted rules between
* rows, a solid rule after every fifth. One row is selected (the row bar);
* one is inactive, with a word.
*/
export function TableCadence() {
return (
<Table strategy="fit">
<TableCaption label="Table 2" note="Counts from the weekly marsh walk.">
Birds counted per month, 2025
</TableCaption>
<TableHead>
<TableRow>
<TableHeaderCell>Month</TableHeaderCell>
<TableHeaderCell numeric>Birds</TableHeaderCell>
<TableHeaderCell numeric>Species</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{counts.map(([month, birds, species]) => (
<TableRow key={month} selected={month === 'May'} inactive={month === 'December'}>
<TableHeaderCell>{month === 'December' ? 'December (partial)' : month}</TableHeaderCell>
<TableCell numeric>{number.format(birds)}</TableCell>
<TableCell numeric>{species}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
A matrix with a pinned column
A matrix with a pinned column
grid draws the full matrix grid in the rule color, for matrices only: marks in cells, centered headers and a legend in the notes. strategy="scroll" pins the first column; once columns scroll under it, it takes the 2 px overflow edge, and scrollCue shows only while the table overflows. strategy="prioritize" hides cells marked optional behind a "Show All n Columns" button; they always print. density="compact" tightens the padding and uses stronger row rules. Without the prop, a table follows the density of the Ground around it, so density="compact" on a Ground compacts every table inside.
Scroll for more allergens →
● Contains. ○ May contain traces. An empty cell: does not contain.
'use client'
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
} from '@fairgarden-private/design/components/Table'
const allergens = ['Gluten', 'Dairy', 'Egg', 'Soy', 'Peanut', 'Tree nut', 'Sesame'] as const
/** ● contains, ○ may contain, empty: does not contain. */
const menu: ReadonlyArray<readonly [string, ReadonlyArray<'●' | '○' | ''>]> = [
['Garden burger', ['●', '', '○', '●', '', '', '●']],
['Harvest bowl', ['', '', '', '●', '', '○', '●']],
['Field greens', ['', '●', '', '', '', '●', '']],
['Oat crumble', ['●', '●', '●', '', '○', '●', '']],
]
/** A matrix: the full grid, the first column pinned while the rest scrolls. */
export function TableMatrix() {
return (
<Table
strategy="scroll"
grid
scrollCue="Scroll for more allergens →"
notes={<p>● Contains. ○ May contain traces. An empty cell: does not contain.</p>}
>
<TableCaption label="Table 3">Allergens by dish</TableCaption>
<TableHead>
<TableRow>
<TableHeaderCell>Dish</TableHeaderCell>
{allergens.map((allergen) => (
<TableHeaderCell key={allergen}>{allergen}</TableHeaderCell>
))}
</TableRow>
</TableHead>
<TableBody>
{menu.map(([dish, marks]) => (
<TableRow key={dish}>
<TableHeaderCell>{dish}</TableHeaderCell>
{marks.map((mark, index) => (
<TableCell key={allergens[index]}>{mark}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)
}
In print
The table always prints in full, never stacked. The header row repeats on every page, rows never split, and the total prints once. Sort glyphs, row checkboxes and row bars are hidden, and every rule prints black at 0.75 pt.
API Reference
Table
A three-rule data table: a --border-size-2 --primary12 frame above and
below, a --border-size-1 --primary12 header rule and hairline row rules,
with no verticals and no zebra stripes [D83]. More than 10 body rows switch
to the five-row cadence. Text cells wrap and are never truncated; numbers
are right-aligned lining tabular figures; an empty cell shows “—".
Compose TableCaption, TableHead, TableBody and TableFoot inside;
rows are TableRow, cells TableHeaderCell and TableCell.
| Prop | Type | Description |
|---|---|---|
density | |
|
grid | | The full matrix grid ( |
interactive | | Rows are click targets: |
notes | | Notes below the table, kept in its own notes block: “Source:” and
“Note:” lines in |
onSolid | | The table sits on a saturated ground (≤ 5 rows, one ink): row rules
become |
primary | | Primary Radix scale: every rule, text and the row bar. Never defaulted; omitted, it inherits the scope [D133]. |
scrollCue | | A text cue above a table that scrolls, e.g. “Scroll for 3 more columns →". Shown only while the table overflows. |
secondary | | Secondary Radix scale: unused by the table itself. Never defaulted. |
showAllLabel | |
|
showFewerLabel | |
|
strategy | | The responsive strategy (§8.2), one per table. |
className | | Class for the root wrapper (the container that holds the cue, the Scroll Area and the notes). |
TableCaption
The caption block above the table: label (“Table 2″), title (the
children, type-subhead) and an optional scope note. It stays in view
while a wide table scrolls.
| Prop | Type | Description |
|---|---|---|
label | | The table label, e.g. “Table 2″, in |
note | | A scope note for units or time zone, in |
TableHead, TableBody and TableFoot
The header row group; its last row carries the header rule. Repeats on every printed page.
The body row group. Row rules are hairlines; over 10 rows they follow the five-row cadence.
| Prop | Type | Description |
|---|---|---|
cadence | | The five-row cadence: dotted |
The total row group, opened by the line-double-hair total rule.
TableRow
A table row. Its cells learn their column, so stacked records can repeat the header labels.
| Prop | Type | Description |
|---|---|---|
inactive | | Sets the row in |
selected | | Draws the |
TableHeaderCell and TableCell
A header cell. In the head it is a column header (type-label caps,
bottom-aligned, aligned like its data; put units here, “Mass (kg)"); in
the body or foot it is a row header (type-body-ui at weight 600).
| Prop | Type | Description |
|---|---|---|
numeric | | Right-aligned lining tabular figures that never wrap; use fixed decimals per column. |
onSort | | Called when the sort button is pressed; the caller updates |
optional | |
|
sort | | Makes the column header a sort button: its label keeps the header’s
caps [D165], a ▲ or ▼ follows it, and |
sortStateLabel | | The sort state as words, appended to the button’s accessible name. Default “, sorted ascending”, “, sorted descending” or “, not sorted”. |
A data cell in type-body-ui with lining tabular figures. Never leave one blank: write “—” and define it in the notes.
| Prop | Type | Description |
|---|---|---|
label | | The column’s label in stacked records. Default: the column header’s
text, read from |
numeric | | Right-aligned lining tabular figures that never wrap; use fixed decimals per column. |
optional | |
|
Additional types
TableBodyProps
Props for TableBody: tbody props plus the cadence override.
type TableBodyProps = React.ClassAttributes<HTMLTableSectionElement> &
React.HTMLAttributes<HTMLTableSectionElement> & { cadence?: boolean }TableCaptionProps
Props for TableCaption: caption props plus the label and the scope note.
type TableCaptionProps = React.ClassAttributes<HTMLElement> &
React.HTMLAttributes<HTMLElement> & { label?: React.ReactNode; note?: React.ReactNode }TableCellProps
Props for TableCell: td props, alignment, and the stacked label.
type TableCellProps = React.ClassAttributes<HTMLTableDataCellElement> &
React.TdHTMLAttributes<HTMLTableDataCellElement> &
CellCommonProps & { label?: React.ReactNode }TableFootProps
Props for TableFoot: tfoot props.
type TableFootProps = React.ComponentPropsWithRef<'tfoot'>TableHeaderCellProps
Props for TableHeaderCell: th props, alignment, and the sort state.
type TableHeaderCellProps = React.ClassAttributes<HTMLTableHeaderCellElement> &
React.ThHTMLAttributes<HTMLTableHeaderCellElement> &
CellCommonProps & {
sort?: TableSort;
onSort?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
sortStateLabel?: string;
}TableHeadProps
Props for TableHead: thead props.
type TableHeadProps = React.ComponentPropsWithRef<'thead'>TableProps
Props for Table: table props, the strategy, density and color axes, and the chrome around the table.
type TableProps = {
/** Class for the root wrapper (the container that holds the cue, the Scroll Area and the notes). */
className?: string;
/**
* The responsive strategy (§8.2), one per table. `stack` (default, for
* records): below 768 px of the table's width each row becomes a block,
* its row header over a leader list of header → value. `fit`: ≤ 4 short
* columns, a table at every width. `scroll`: comparison matrices; the
* first column stays pinned while the rest scrolls. `prioritize`: cells
* marked `optional` hide behind a "Show All n Columns" toggle; they always
* print.
*/
strategy?: 'stack' | 'fit' | 'scroll' | 'prioritize' | null;
/** The full matrix grid (`--border-size-1` `--role-rule`), for matrices only: marks in cells, centered headers. */
grid?: boolean;
/**
* `compact`: `--size-px-2` × `--ds-space-12` cell padding, rows at least
* 32 px, `--role-rule` row rules. `default`: `--ds-space-12` ×
* `--size-px-3`. Never defaulted: omitted, the table follows its scope's
* density (a Ground's `density`, through the `--ds-table-*` tokens).
*/
density?: 'compact' | 'default' | null;
/** Rows are click targets: `--role-rule` row rules and rows at least 44 px tall. */
interactive?: boolean;
/**
* The table sits on a saturated ground (≤ 5 rows, one ink): row rules
* become `line-dotted-fine` in `--role-rule`. Longer data belongs on a
* nested `white` Ground.
*/
onSolid?: boolean;
/**
* Primary Radix scale: every rule, text and the row bar. Never defaulted;
* omitted, it inherits the scope [D133].
*/
primary?:
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null;
/** Secondary Radix scale: unused by the table itself. Never defaulted. */
secondary?:
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null;
/**
* A text cue above a table that scrolls, e.g. "Scroll for 3 more columns
* →". Shown only while the table overflows.
*/
scrollCue?: React.ReactNode;
/** `prioritize` only: the toggle label while optional columns are hidden. Default "Show All n Columns". */
showAllLabel?: string;
/** `prioritize` only: the toggle label while they show. Default "Show Fewer Columns". */
showFewerLabel?: string;
/**
* Notes below the table, kept in its own notes block: "Source:" and
* "Note:" lines in `type-small` and lettered cell notes (§8.12). Define
* "—" (empty cell) here.
*/
notes?: React.ReactNode;
}TableRowProps
Props for TableRow: tr props plus the selected and inactive states.
type TableRowProps = React.ClassAttributes<HTMLTableRowElement> &
React.HTMLAttributes<HTMLTableRowElement> & { selected?: boolean; inactive?: boolean }TableSort
The column’s sort state, mirrored to aria-sort.
type TableSort = 'ascending' | 'descending' | 'none'Specification: DESIGN-SYSTEM.md §8.2 (tables), §5.10.2 (reflow) and §10.19 (the scroll edge).