Data Table
For comparing rows against each other. If the user is not comparing, a list of cards reads better and survives mobile.
Also called Table, Data Grid, Grid — in this system all of them are Data Table.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
| auth-service | live | 2,004,918 | |
| api-gateway | live | 1,240,331 | |
| edge-cache | live | 412,005 | |
| billing-worker | failed | 84,112 | |
| webhook-relay | live | 55,301 | |
| search-index | building | 9,820 |
Responsive fallback
Below md the table becomes a card list carrying the same fields as a definition list. Horizontal scrolling a table on a phone is the pattern this replaces.
Empty, loading, error
Three states that every table needs and most tables forget. Note that the empty state distinguishes "no data yet" from "no results for this filter" — they need different actions.
Density
The same table at three row heights. Ship compact as a preference, never as the default — new users need the relaxed version to learn the structure.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
| api-gateway | live | 1,240,331 |
| edge-cache | live | 412,005 |
| billing-worker | failed | 84,112 |
Header, rows, and the alignment rules. The only vertical structure comes from the columns themselves.
- Header height36px
Shorter than a row, on an inset background. It is a label strip, not a row of data, and the height difference says so.
- Row height36 / 44 / 56px
Compact, normal, relaxed. 44px is the default because it holds two lines of metadata and clears the touch minimum.
- Cell padding14px horizontal
The same on both sides so a right-aligned number and a left-aligned label are optically equidistant from the column edge.
- Ruling1px horizontal only
At 6% alpha. Vertical rules add roughly twenty lines per screen and turn a table into a spreadsheet.
- Numeric alignmentRight, tabular-nums
Units line up in a vertical strip, so magnitude is readable as shape. This is the highest-value formatting rule in any table.
- Sort affordanceHidden until hover
A permanent ⇅ on every sortable column is twelve pieces of chrome nobody is looking at. It appears on hover and stays once sorted.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface | — | Table background |
| --ds-surface-inset | — | Header row |
| --ds-border-subtle | — | Row rules and the outer edge |
| --ds-layer-hover | — | Row hover |
| --ds-layer-selected | — | Selected row |
| --ds-accent-subtle | — | Bulk-action bar |
| --ds-fg-muted | — | Header labels |
Spacing
| Token | Value | Used for |
|---|---|---|
| cell padding | compact / normal / relaxed | |
| row height | The three densities |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-xl | Table wrapper corners |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-body-sm | Cell content | |
| --text-label-sm | Header labels |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Type | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|
| Compact | 36px | 0 12px | 13px | — | — | pointer only | Power users on large screens. Offer it; never default to it. |
| Normal | 44px | 0 14px | 13px | — | — | 44px | The default. Holds two lines of metadata and clears the touch minimum. |
| Relaxed | 56px | 0 16px | 13px | — | — | 56px | Rows with avatars, thumbnails, or two-line cells. |
| Header | 36px | 0 14px | 12px / 500 | — | — | — | Always shorter than a data row. |
| Selection column | — | — | — | 40px | — | — | Fixed width. Never let it flex. |
| Numeric column | — | — | — | — | 10ch | — | Right-aligned, tabular. Wide enough for the largest plausible value. |
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Header labels are muted but must still reach 4.5:1 — they are text, not decoration.
- Row hover and row selected must be distinguishable from each other and from the default row. Three states, three distinct values.
- Status badges inside cells follow the badge rules: colour plus a word, never colour alone.
Keyboard
| Tab | Moves through interactive elements in the table: sort buttons, checkboxes, row actions. |
| Space | Toggles a focused row checkbox. |
| Enter | Activates a sort header or a row link. |
| Shift + click | Range-selects between the last selected row and this one. |
| ⌘/Ctrl + A | Selects all rows when focus is inside the table. |
| ↑ / ↓ | Optional row-level focus in a grid-style table. Requires role="grid". |
Screen readers
- Header cells must use <th scope="col">. Without scope, cells are announced with no column context and the table is unusable.
- Announce the result count after filtering or sorting: "Sorted by requests, descending. 248 rows."
- Do not put essential information in a column that is hidden at small widths — hidden means gone for assistive tech too.
Focus & touch
- With a sticky header, set scroll-padding-top on the scroll container so a focused row is never hidden underneath it. This is WCAG 2.4.11 and it is very easy to miss.
- Rows are 44px at normal density. Keep the checkbox column at least 40px wide so the tap target does not overlap the first data column, and never put two adjacent icon buttons in a row without 8px between them.
| Attribute | Applied to | Notes |
|---|---|---|
| <table> + <th scope> | Structure | Real table semantics. A grid of divs with role="table" is possible but almost always implemented incompletely. |
| aria-sort | The sorted <th> | "ascending", "descending" or absent. Only one header carries it at a time. |
| <caption> | The table | Names the table for screen-reader users. Visually hidden is fine. |
| aria-label | Row checkboxes | "Select api-gateway", not "Select row". A column of identical names is useless. |
| aria-live="polite" | The selection count | Announces "3 selected" without moving focus. |
| aria-busy | <tbody> while loading | On the container, so the update is not announced row by row. |
| aria-rowcount | A virtualised table | The total, not the rendered count — otherwise it announces "row 12 of 20" in a table of 5,000. |
Example usage
1import { DataTable, TableToolbar, type Column } from '@/ui/Table'23const columns: Column<Deploy>[] = [4 {5 id: 'service',6 header: 'Service',7 sortBy: (r) => r.service, // omit to make the column unsortable8 sticky: true, // pins during horizontal scroll9 cell: (r) => <span className="font-mono">{r.service}</span>,10 },11 {12 id: 'requests',13 header: 'Requests',14 align: 'right',15 numeric: true, // right-aligned + tabular figures16 sortBy: (r) => r.requests,17 cell: (r) => nf.format(r.requests),18 },19 {20 id: 'author',21 header: 'Deployed by',22 hideBelow: 'lg', // shed the least decision-relevant first23 cell: (r) => <Author name={r.author} />,24 },25]2627<DataTable28 columns={columns}29 rows={rows}30 rowKey={(r) => r.id}31 sort={sort}32 onSortChange={setSort}33 selectable34 selected={selected}35 onSelectedChange={setSelected}36 density={density}37 stickyHeader38 caption="Recent deployments"39 emptyState={<EmptyState … />}40/>4142// Responsive: a card list below md, the table above it43<div className="md:hidden"><TableCardList … /></div>44<div className="hidden md:block"><DataTable … /></div>Framework-free HTML
<div class="ds-table-wrap">
<table class="ds-table">
<caption class="sr-only">Recent deployments</caption>
<thead>
<tr>
<th scope="col" class="ds-table__select">
<input type="checkbox" aria-label="Select all rows" />
</th>
<th scope="col">Service</th>
<th scope="col" aria-sort="descending">
<button type="button">Requests <span aria-hidden="true">↓</span></button>
</th>
</tr>
</thead>
<tbody>
<tr>
<td><input type="checkbox" aria-label="Select api-gateway" /></td>
<th scope="row">api-gateway</th>
<td class="ds-table__num">1,240,331</td>
</tr>
</tbody>
</table>
</div>
<p class="sr-only" role="status" aria-live="polite">3 rows selected</p>CSS
.ds-table { inline-size: 100%; border-collapse: collapse; font-size: 13px; }
/* Horizontal rules only. Vertical rules turn this into a spreadsheet. */
.ds-table tbody tr { border-block-end: 1px solid var(--ds-border-subtle); }
.ds-table tbody tr:last-child { border-block-end: 0; }
.ds-table th {
block-size: 36px; /* shorter than a data row */
padding-inline: 14px;
text-align: start;
font-size: 12px;
font-weight: 500;
color: var(--ds-fg-muted);
background: var(--ds-surface-inset);
white-space: nowrap;
}
.ds-table td { block-size: 44px; padding-inline: 14px; }
/* The single highest-value rule in any table */
.ds-table__num {
text-align: end;
font-variant-numeric: tabular-nums;
}
.ds-table tbody tr:hover { background: var(--ds-layer-hover); }
.ds-table tbody tr[data-selected] { background: var(--ds-layer-selected); }
/* Sticky header. scroll-padding stops it covering a focused row. */
.ds-table-wrap { overflow: auto; scroll-padding-block-start: 36px; }
.ds-table thead { position: sticky; inset-block-start: 0; z-index: 1; }
/* Sticky first column during horizontal scroll */
.ds-table__sticky {
position: sticky;
inset-inline-start: 0;
background: var(--ds-surface);
}
/* The sort affordance appears on hover and stays once sorted */
.ds-table th .sort-icon { opacity: 0; transition: opacity 120ms; }
.ds-table th:hover .sort-icon,
.ds-table th[aria-sort] .sort-icon { opacity: 1; }Component API
DataTable
| Prop | Type | Default | Description |
|---|---|---|---|
| columns* | Column<T>[] | — | Column definitions. Memoise them — they run per row per render. |
| rows* | T[] | — | The current page of data. Sorting is applied internally when sortBy is present. |
| rowKey* | (row: T) => string | — | Stable identity. Never use the array index. |
| sort | { id, dir } | null | — | Controlled sort state. Cycles asc → desc → none. |
| selectable | boolean | false | Adds a checkbox column with a header select-all. |
| density | 'compact' | 'normal' | 'relaxed' | 'normal' | 36 / 44 / 56px rows. |
| stickyHeader | boolean | false | Pins the header. Set scroll-padding on the container too. |
| emptyState | ReactNode | — | Rendered in a full-width cell when there are no rows. |
| caption | string | — | Visually hidden table name for screen readers. |
Column<T>
| Prop | Type | Default | Description |
|---|---|---|---|
| cell* | (row: T) => ReactNode | — | Keep it pure — it runs for every row on every render. |
| sortBy | (row: T) => string | number | — | Presence makes the column sortable. |
| align | 'left' | 'right' | 'center' | 'left' | 'right' for every numeric column. |
| numeric | boolean | false | Applies tabular figures. |
| hideBelow | 'sm' | 'md' | 'lg' | — | Responsive shedding. Drop the least decision-relevant first. |
| sticky | boolean | false | Pins the column during horizontal scroll. First column only. |
Professional tips
- Sort by the column users care about most, descending, by default. An unsorted table makes every visit start with a click.
- Show the total row count near the pagination. "1–20 of 248" answers a question users ask on every table.
- Keep the row action column to one icon button plus an overflow menu. Four inline actions per row multiplied by twenty rows is eighty targets.
- Persist sort, filters and density in the URL. A table state that vanishes on refresh cannot be shared with a colleague.
Performance
- Virtualise past about 200 rows. Below that the DOM cost is negligible and virtualisation adds real complexity — including breaking Ctrl+F.
- Memoise the column definitions. Recreating them each render invalidates every memoised row.
- Sort and filter on the server once the dataset passes a few thousand rows. Client-side sorting of 50,000 rows blocks the main thread for seconds.
- Store selection in a Set keyed by id. An array with .includes() is O(n) per row and turns selection into an O(n²) render.
Common mistakes
- Left-aligning numbers, so magnitudes cannot be compared at a glance.
- Forgetting scope on header cells, which leaves screen-reader users with no column context.
- Using the array index as the row key, so sorting scrambles React state and selection follows the wrong rows.
- A sticky header without scroll-padding, so tabbing to a row scrolls it underneath the header.
- Announcing every row as it loads instead of setting aria-busy on the tbody.
Real-world recommendations
- Watch a real user with a real dataset before designing the table. The column they scan first should be leftmost, and it is often not the one that was designed to be.
- Bulk selection needs an explicit "select all 1,432 matching" separate from "select all 20 on this page". Conflating them causes genuinely destructive accidents.
- Column resizing and reordering are expensive to build and rarely used. Column visibility toggles deliver most of the value for a fraction of the cost.
- Export is not optional in an enterprise table. Someone will paste this into a spreadsheet regardless — make it a button instead of a copy-paste job.