Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Recent deployments
auth-servicelive2,004,918
api-gatewaylive1,240,331
edge-cachelive412,005
billing-workerfailed84,112
webhook-relaylive55,301
search-indexbuilding9,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.

Use the phone and tablet width controls above to switch between them.

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.

No results

No deployments match the current filters.

api-gatewaylive
billing-workerfailed

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.

compact

api-gatewaylive
billing-workerfailed
edge-cachelive

normal

api-gatewaylive
billing-workerfailed
edge-cachelive

relaxed

api-gatewaylive
billing-workerfailed
edge-cachelive

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.

api-gateway42s
Default row
api-gateway42s
Hover
api-gateway42s
Selected
api-gateway42s
Focus
Requests ↑
Sorted asc
Requests ↓
Sorted desc
Requests ⇅
Unsorted
Loading
No rows
Empty
3 selected
Bulk bar
Header pinned
Sticky header
1,240,331
NumericRight, tabular

Anatomy

Every part, every measurement, and the reason it is that number.

api-gatewaylive1,240,331
edge-cachelive412,005
billing-workerfailed84,112

Header, rows, and the alignment rules. The only vertical structure comes from the columns themselves.

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

  2. Row height36 / 44 / 56px

    Compact, normal, relaxed. 44px is the default because it holds two lines of metadata and clears the touch minimum.

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

  4. Ruling1px horizontal only

    At 6% alpha. Vertical rules add roughly twenty lines per screen and turn a table into a spreadsheet.

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

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

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
cell paddingcompact / normal / relaxed
row heightThe three densities

Radius

TokenValueUsed for
--radius-xlTable wrapper corners

Typography

TokenValueUsed for
--text-body-smCell content
--text-label-smHeader labels

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingTypeMin widthMax widthTouch targetWhen to use
Compact36px0 12px13px——pointer onlyPower users on large screens. Offer it; never default to it.
Normal44px0 14px13px——44pxThe default. Holds two lines of metadata and clears the touch minimum.
Relaxed56px0 16px13px——56pxRows with avatars, thumbnails, or two-line cells.
Header36px0 14px12px / 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.

Do

1,240,331
84,112
2,004,918
Right-align numbers with tabular figuresUnits land in the same column on every row, so orders of magnitude are visible as shape. Left-aligned numbers force the reader to count digits.
Service
api-gateway
billing-worker
edge-cache
search-index
auth-service
webhook-relay
Keep the header visible while scrollingPast about fifteen rows the user has forgotten which column is which. A sticky header at e2 costs nothing and removes constant scrolling back to the top.
3 selected · Export · Delete
Replace the filter row with bulk actions in placePushing the table down when a row is selected moves the row you just clicked out from under the cursor. Swap the toolbar contents instead.
lg — service · env · status · author · build · requestsmd — service · env · status · requestssm — service · status · requests
Drop the least decision-relevant columns firstResponsive tables should shed columns in a deliberate order, not scroll horizontally. The identifier and the status survive to the smallest size.

Don't

api-gatewayproduction42s
billing-workerproduction11s
edge-cachestaging68s
Do not add vertical rulesA twelve-column table gains eleven vertical lines on every screen. Alignment already separates the columns; the rules only add noise.
ServiceEnvironmentStatusAuthorBuildRequestsRegion
Do not scroll a table horizontally on mobileColumns off-screen are columns nobody reads, and horizontal scroll inside a vertically scrolling page is a constant gesture conflict.
api-gateway
billing-worker
edge-cache
search-index
Do not zebra-stripeStriping was a fix for tables with no row rules. With a 1px rule and adequate row height it just adds a second competing pattern and makes hover harder to see.
click checkbox → row navigates → selection lost
Do not make the whole row clickable and also selectableClicking a row navigates; clicking the checkbox selects. If the checkbox does not stop propagation, selecting a row navigates away from it.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA1.3.2Meaningful SequenceA2.1.1KeyboardA2.4.11Focus Not ObscuredAA4.1.2Name, Role, ValueA

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

TabMoves through interactive elements in the table: sort buttons, checkboxes, row actions.
SpaceToggles a focused row checkbox.
EnterActivates a sort header or a row link.
Shift + clickRange-selects between the last selected row and this one.
⌘/Ctrl + ASelects 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.
AttributeApplied toNotes
<table> + <th scope>StructureReal table semantics. A grid of divs with role="table" is possible but almost always implemented incompletely.
aria-sortThe sorted <th>"ascending", "descending" or absent. Only one header carries it at a time.
<caption>The tableNames the table for screen-reader users. Visually hidden is fine.
aria-labelRow checkboxes"Select api-gateway", not "Select row". A column of identical names is useless.
aria-live="polite"The selection countAnnounces "3 selected" without moving focus.
aria-busy<tbody> while loadingOn the container, so the update is not announced row by row.
aria-rowcountA virtualised tableThe total, not the rendered count — otherwise it announces "row 12 of 20" in a table of 5,000.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
selectablebooleanfalseAdds a checkbox column with a header select-all.
density'compact' | 'normal' | 'relaxed''normal'36 / 44 / 56px rows.
stickyHeaderbooleanfalsePins the header. Set scroll-padding on the container too.
emptyStateReactNode—Rendered in a full-width cell when there are no rows.
captionstring—Visually hidden table name for screen readers.

Column<T>

PropTypeDefaultDescription
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.
numericbooleanfalseApplies tabular figures.
hideBelow'sm' | 'md' | 'lg'—Responsive shedding. Drop the least decision-relevant first.
stickybooleanfalsePins the column during horizontal scroll. First column only.

Notes

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.