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.
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 Lovelace | Intake & triage | Northeast | Active | 4 mi | 36 | |
| Barbara McClintock | Community outreach | Southeast | Paused | 22 mi | 11 | |
| Chien-Shiung Wu | Medical | West | Active | 19 mi | 92 | |
| Dorothy Vaughan | Intake & triage | Midwest | Active | 9 mi | 78 | |
| Emmy Noether | Intake & triage | Midwest | Onboarding | 7 mi | 3 | |
| Grace Hopper | Transport | Northeast | Active | 28 mi | 120 | |
| Hedy Lamarr | Transport | Southeast | Onboarding | 33 mi | 5 | |
| Katherine Johnson | Medical | Southeast | Onboarding | 12 mi | 8 | |
| Lise Meitner | Foster network | Northeast | Active | 2 mi | 41 | |
| Mae Jemison | Community outreach | West | Active | 6 mi | 64 | |
| Rosalind Franklin | Foster network | Midwest | Paused | 41 mi | 14 | |
| Vera Rubin | Transport | West | Active | 51 mi | 133 |
- 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 Lovelace | Intake & triage | Northeast | Active | 4 mi | 36 |
| Grace Hopper | Transport | Northeast | Active | 28 mi | 120 |
| Katherine Johnson | Medical | Southeast | Onboarding | 12 mi | 8 |
| Mae Jemison | Community outreach | West | Active | 6 mi | 64 |
| Rosalind Franklin | Foster network | Midwest | Paused | 41 mi | 14 |
density="compact"
| Ada Lovelace | Intake & triage | Northeast | Active | 4 mi | 36 |
| Grace Hopper | Transport | Northeast | Active | 28 mi | 120 |
| Katherine Johnson | Medical | Southeast | Onboarding | 12 mi | 8 |
| Mae Jemison | Community outreach | West | Active | 6 mi | 64 |
| Rosalind Franklin | Foster network | Midwest | Paused | 41 mi | 14 |
- 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
- 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 Lovelace | Intake & triage | Northeast | Active | 4 mi | 36 |
| Chien-Shiung Wu | Medical | West | Active | 19 mi | 92 |
| Grace Hopper | Transport | Northeast | Active | 28 mi | 120 |
| Katherine Johnson | Medical | Southeast | Onboarding | 12 mi | 8 |
| Mae Jemison | Community outreach | West | Active | 6 mi | 64 |
| Rosalind Franklin | Foster network | Midwest | Paused | 41 mi | 14 |
- 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)
- 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.