Resolved: light

Data table

The grid the roster runs on — typed, controlled, theme-aware, and assembled entirely from @phauna/ds/grid primitives. A DataGrid takes a typed Column<T>[] and rows plus a getRowId, and gives you sortable headers, row selection, a sticky header, density variants, custom cell renderers, right-aligned numeric columns, built-in empty + loading states, CSV/XLSX export, and column show/hide/reorder — with sort, selection and column layout all liftable to controlled state. For very large datasets the VirtualDataGrid speaks the same Column<T> contract but windows the body. This is the plain-table companion to the Filters page, which layers the faceted FilterBar over the same grid.

Column<T>
{ key (a real keyof T), header, sortable?, align?, render?(row), exportValue?(row), width?, pinned?: "left", hidden? }. key drives sorting + export; render owns the cell's JSX; exportValue supplies the raw CSV/XLSX value.
DataGrid<T>
Required: rows, columns, getRowId. Optional & controllable: selectable + selected/onSelectedChange, sort/onSortChange, columnState/onColumnStateChange. Plus stickyHeader, stickyFirst, density, empty, loading, toolbar.
getRowId(row)
Stable string|number identity per row — drives selection keys and React reconciliation. Required on both DataGrid and VirtualDataGrid.
controlled vs uncontrolled
Omit sort / selected / columnState and the grid keeps its own internal state. Pass them (with their on*Change) to lift state into the host — e.g. to persist a layout or share a sorted view.
import path
Everything here imports from "@savethemarshalldogs/design-system/grid"; Badge / Button / EmptyState come from "@savethemarshalldogs/design-system"; SortState from "@savethemarshalldogs/design-system/hooks". The grid CSS ships via catalog/grid.css (already imported in globals.css).
client-only
The grid family is client (sort/selection/menus touch the DOM). This page is a server component; each interactive grid lives in the colocated ./DataGridDemo 'use client' island.
tsx
import { DataGrid, type Column } from "@savethemarshalldogs/design-system/grid";

type Row = { id: string; name: string; city: string };
const columns: Column<Row>[] = [
  { key: "name", header: "Name", sortable: true },
  { key: "city", header: "City" },
];

<DataGrid
  rows={rows}
  columns={columns}
  getRowId={(r) => r.id}
  selectable
  stickyHeader
/>

The full grid (interactive)

The complete DataGrid, live. Click any header to sort (controlled SortState); tick the leading checkboxes to select rows — a BulkActionBar slides in with the count and actions, and disappears when selection clears. The header stays pinned while the body scrolls (stickyHeader), and the first column is frozen to the left edge on horizontal scroll (stickyFirst). Team and Status use custom cell renderers (a label lookup and a Badge); Distance and Hours are right-aligned numeric columns. Export the current rows to CSV/XLSX from the toolbar button, or just the selected rows from the bulk bar. Flip the theme in the top bar — every part re-skins via the semantic tokens.

12 volunteers · sticky header · click a header to sort

Ada LovelaceIntake & triageNortheastActive4 mi36
Barbara McClintockCommunity outreachSoutheastPaused22 mi11
Chien-Shiung WuMedicalWestActive19 mi92
Dorothy VaughanIntake & triageMidwestActive9 mi78
Emmy NoetherIntake & triageMidwestOnboarding7 mi3
Grace HopperTransportNortheastActive28 mi120
Hedy LamarrTransportSoutheastOnboarding33 mi5
Katherine JohnsonMedicalSoutheastOnboarding12 mi8
Lise MeitnerFoster networkNortheastActive2 mi41
Mae JemisonCommunity outreachWestActive6 mi64
Rosalind FranklinFoster networkMidwestPaused41 mi14
Vera RubinTransportWestActive51 mi133
DataGrid: controlled sort + selection, stickyHeader/stickyFirst, custom cells, numeric align, GridExportButton + BulkActionBar
selectable + selected
selectable shows the checkbox column; selected is a ReadonlySet<string> of row ids and onSelectedChange emits the next set. Controlled here so the BulkActionBar can read the same selection.
sort + onSortChange
SortState is { key, dir: "asc"|"desc" } | null. The grid emits the clicked column's key; the host owns the comparator (here a tiny numeric-aware sort) and feeds back the sorted rows.
stickyHeader / stickyFirst
stickyHeader pins the <thead> during vertical scroll; stickyFirst freezes the first (here pinned:"left") column during horizontal scroll. Both are pure CSS on the shipped .data-table substrate.
render vs align
render(row) returns the cell node — a Badge for Status, a formatted string for Distance/Hours. align:"right" right-justifies a column (use it for numerics); exportValue keeps the export numeric.
GridExportButton
A drop-in button that downloads the given rows+columns as CSV/XLSX (dynamic-imports papaparse / xlsx, so they stay off the hot path). exportCSV / exportXLSX are the bare helpers it wraps.
BulkActionBar
Renders only when count > 0. Shows the selection count, your action children, and an onClear affordance. Pair it with controlled selection so its actions operate on the selected rows.

Density

The same columns and rows at two row heights. comfortable (the default) is the reading-first density; compact tightens the rows for dense, scan-heavy admin tables. Density is a single prop — no column or CSS changes.

density="comfortable" (default)

Ada LovelaceIntake & triageNortheastActive4 mi36
Grace HopperTransportNortheastActive28 mi120
Katherine JohnsonMedicalSoutheastOnboarding12 mi8
Mae JemisonCommunity outreachWestActive6 mi64
Rosalind FranklinFoster networkMidwestPaused41 mi14

density="compact"

Ada LovelaceIntake & triageNortheastActive4 mi36
Grace HopperTransportNortheastActive28 mi120
Katherine JohnsonMedicalSoutheastOnboarding12 mi8
Mae JemisonCommunity outreachWestActive6 mi64
Rosalind FranklinFoster networkMidwestPaused41 mi14
density="comfortable" vs density="compact"
density="comfortable"
The default. Taller rows, more breathing room — best for tables a human reads top to bottom.
density="compact"
Shorter rows for high-volume admin grids where fitting more on screen beats whitespace.

Empty & loading states

A table is never a blank rectangle. With rows=[] the grid renders the empty slot you pass — an EmptyState tuned to the situation (variant 'empty' for a fresh table, 'no-results' after filtering). With loading the grid shows its own skeleton rows so layout stays stable while data arrives.

rows={[]} → empty slot

No volunteers match
Adjust the filters to widen the search.

loading → skeleton rows

rows=[] → empty slot · loading → skeleton rows
empty
A node rendered in place of the body when rows is empty. Use EmptyState with the right variant — "empty" (nothing yet), "no-results" (filtered to zero), "error", "permission".
loading
When true the grid renders placeholder skeleton rows instead of the body, preserving column widths and header so there's no layout shift on data arrival.

Columns — show / hide / reorder

ColumnsButton is the standalone column manager: a menu to toggle column visibility and reorder columns. Wire it to a controlled ColumnLayout that the grid also renders (and set showColumnsButton={false} on the grid so the built-in toolbar control doesn't double up). The layout is plain, serializable state — { order, hidden, pinned, widths? } — so an app can persist a user's table layout to the URL or a DB row.

show / hide / reorder — controlled columnState shared with the grid

Ada LovelaceIntake & triageNortheastActive4 mi36
Chien-Shiung WuMedicalWestActive19 mi92
Grace HopperTransportNortheastActive28 mi120
Katherine JohnsonMedicalSoutheastOnboarding12 mi8
Mae JemisonCommunity outreachWestActive6 mi64
Rosalind FranklinFoster networkMidwestPaused41 mi14
ColumnsButton (standalone) + DataGrid sharing one controlled columnState
ColumnsButton
Props: columns, columnState, onColumnStateChange. Stands alone or inside a toolbar; it mutates the shared ColumnLayout the grid reads.
defaultColumnLayout(columns)
Seeds a ColumnLayout from the column defs — declared order, hidden columns hidden, pinned:"left" columns pinned, no width overrides. The natural initial value.
ColumnLayout
{ order: string[], hidden: string[], pinned: string[], widths?: Record<string,number> } — serializable, so a full table layout round-trips to the URL or a DB row.
showColumnsButton
Set false on the DataGrid when you mount your own <ColumnsButton/> elsewhere (sharing the same controlled columnState) so the toolbar control isn't duplicated.

VirtualDataGrid — windowing

For large datasets the VirtualDataGrid is the DataGrid's row-virtualized cousin: it speaks the SAME Column<T> contract and reuses the same .data-table chrome (a sticky header over a windowed body), but only renders the rows in (and just around) the viewport, so a 600- or 100k-row table stays smooth. It needs a fixed height for the scroll window and the same getRowId. (Sort / selection / columns are the eager DataGrid's job — reach for VirtualDataGrid when raw row count is the constraint.)

VirtualDataGrid · windowed body (600+ rows, height=420)

Windowed grid demo paused
VirtualDataGrid currently trips an upstream @phauna/ds hook-order bug (React #310). Its API is documented below; the live windowed demo returns once the package is patched to call the virtualizer unconditionally.
VirtualDataGrid: 600 rows, height=420, same Column<T> — only the visible slice is in the DOM
VirtualDataGrid<T>
Required: rows, columns, getRowId, height. The body is windowed via @tanstack/react-virtual (an optional peer); until it loads (or with zero rows) it renders a plain non-windowed pass — correct, just not windowed.
height
The viewport height (px number or CSS length) — required, since windowing measures against a fixed scroll element.
estimateSize / overscan
estimateSize is the estimated row height (defaults to the 44px row token); overscan is how many extra rows to render beyond the viewport on each side. Tune for smoother fast scrolls.
same Column<T>
Reuse your DataGrid columns verbatim — render, align and width all apply. The difference is purely the windowed body; the header chrome is identical.

Export helpers

The grid ships CSV/XLSX export so a table is always extractable. GridExportButton is the drop-in UI (used in the full grid above); exportCSV / exportXLSX are the bare helpers if you want your own trigger — e.g. exporting only the selected rows from a BulkActionBar. Each column's exportValue(row) supplies the raw cell value (falling back to row[key]), so exports carry numbers and labels, not formatted JSX.

GridExportButton
Props: rows, columns, filename?. A button that downloads CSV/XLSX for the given rows+columns. Dynamic-imports papaparse / xlsx so neither lands in the initial bundle.
exportCSV(rows, columns, filename?)
Triggers a client-side CSV download (Blob + a[download]). Used in the bulk bar above to export just the selected rows.
exportXLSX(rows, columns, filename?)
Same, as an .xlsx workbook via the xlsx package (dynamic import).
toCSV(rows, columns)
Returns the CSV string instead of downloading — for when you need to attach, POST, or otherwise handle the bytes yourself.
exportValue(row)
Per-column raw export value (falls back to row[key]). Set it on rendered columns so the export gets the underlying number/label, not the cell's formatted string or Badge.