FairGarden Design

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.

TableRecords.tsx
'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.

TableCadence.tsx
'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.

TableMatrix.tsx
'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.

PropTypeDescription
density
'compact' | 'default' | null | undefined

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).

grid
boolean | undefined

The full matrix grid (--border-size-1 --role-rule), for matrices only: marks in cells, centered headers.

interactive
boolean | undefined

Rows are click targets: --role-rule row rules and rows at least 44 px tall.

notes
React.ReactNode | undefined

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.

onSolid
boolean | undefined

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.

primary
| 'ruby'
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale: every rule, text and the row bar. Never defaulted; omitted, it inherits the scope [D133].

scrollCue
React.ReactNode | undefined

A text cue above a table that scrolls, e.g. “Scroll for 3 more columns →". Shown only while the table overflows.

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
| undefined

Secondary Radix scale: unused by the table itself. Never defaulted.

showAllLabel
string | undefined

prioritize only: the toggle label while optional columns are hidden. Default “Show All n Columns”.

showFewerLabel
string | undefined

prioritize only: the toggle label while they show. Default “Show Fewer Columns”.

strategy
| 'stack'
| 'fit'
| 'scroll'
| 'prioritize'
| null
| undefined

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.

className
string | undefined

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.

PropTypeDescription
label
React.ReactNode | undefined

The table label, e.g. “Table 2″, in type-data.

note
React.ReactNode | undefined

A scope note for units or time zone, in type-caption.

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.

PropTypeDescription
cadence
boolean | undefined

The five-row cadence: dotted --role-rule rules between rows and a solid one after every fifth row. Default: on when the body holds more than 10 rows.

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.

PropTypeDescription
inactive
boolean | undefined

Sets the row in --role-muted. Always add a word (“Sold out”): never tone alone.

selected
boolean | undefined

Draws the --ds-stroke-3 row bar at the start edge. A row holding a checked Checkbox, or the :target of a link (give it an id), shows the bar without this prop.

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).

PropTypeDescription
numeric
boolean | undefined

Right-aligned lining tabular figures that never wrap; use fixed decimals per column.

onSort
| ((
    event: React.MouseEvent<
      HTMLButtonElement,
      MouseEvent
    >,
  ) => void)
| undefined

Called when the sort button is pressed; the caller updates sort and the rows.

optional
boolean | undefined

prioritize tables: hidden until “Show All n Columns”; always printed.

sort
TableSort | undefined

Makes the column header a sort button: its label keeps the header’s caps [D165], a ▲ or ▼ follows it, and aria-sort carries the state.

sortStateLabel
string | undefined

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.

PropTypeDescription
label
React.ReactNode | undefined

The column’s label in stacked records. Default: the column header’s text, read from TableHead.

numeric
boolean | undefined

Right-aligned lining tabular figures that never wrap; use fixed decimals per column.

optional
boolean | undefined

prioritize tables: hidden until “Show All n Columns”; always printed.

Additional types

table
type table = table
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).