ComponentsData Table
Data Table
sectionThe flagship data surface — toolbar, selection, sticky header, pagination and every state.
Data table
Toolbar, selection, every state & pagination — the flagship block.
Best practices
Do
- Reach for .data-table for ANY data surface — it owns the toolbar, selection, sticky header, pagination and the empty/loading/error states.
- Put numeric columns in .num cells (th + td) — right-aligned, tabular numerals, so totals line up.
- On a narrow container let the container query reflow it; freeze the identity column with .tbl__col--frozen when it scrolls horizontally.
Don't
- Don't hand-roll a table out of .card + flex rows — density, hover, selection and the states are already guaranteed here.
- Don't bolt a separate search input above it — the toolbar slot is part of the recipe.
| Service | Owner | Requests | Status | |
|---|---|---|---|---|
| ai-router | Ava | 1.820 | Healthy | |
| pricing-engine-internal-v2 | Jordan | 942 | Healthy | |
| edge-cache | Casey | 12.840 | Degraded | |
| payments-prod | Eliot | 0 | Down | |
| background-jobs | Kira | 388 | Healthy | |
| analytics-etl | Greta | 5.102 | Degraded |
1–6 of 6Rows
Composes: Table · Toolbar · Breadcrumb · Select
Best practices
Do
- Reach for .data-table for ANY data surface — it owns the toolbar, selection, sticky header, pagination and the empty/loading/error states.
- Put numeric columns in .num cells (th + td) — right-aligned, tabular numerals, so totals line up.
- On a narrow container let the container query reflow it; freeze the identity column with .tbl__col--frozen when it scrolls horizontally.
Don't
- Don't hand-roll a table out of .card + flex rows — density, hover, selection and the states are already guaranteed here.
- Don't bolt a separate search input above it — the toolbar slot is part of the recipe.
Recipe CSS
This is the exact CSS your kit ships for Data Table — token-driven, so it re-themes with every knob. Get it (and the rest) from the configurator's Use kit panel.
/* === Data table ===
The flagship BLOCK: a bordered frame that wraps the .tbl atom into a complete,
believable data surface — matrix-complete across every state real data hits.
It COMPOSES atoms (.toolbar · .tbl · .select / .select-trigger · .pagination),
so the whole surface is rebuildable from the kit, not a one-off.
Anatomy
.datatable frame (border + radius + clip)
.datatable__bar header band — holds a .toolbar (search /
.datatable__bar--active filters / actions); swaps to the bulk-action
bar when rows are selected
.datatable__body scroll well (sticky thead)
table.tbl the table atom
.datatable__state centered slot for EMPTY / LOADING / ERROR
.datatable__foot footer band — row count · rows-per-page
.select · .pagination
Modifiers
.datatable--page page-level table: body grows to natural
height, the PAGE scrolls (no inner well)
.datatable--loading dims the body while skeleton rows show
State slot
.datatable__state--error tints the icon danger for a failed load */
.datatable { border: 1px solid var(--k-border); border-radius: var(--k-radius-md); background: var(--k-surface); overflow: hidden; container: datatable / inline-size; }
/* Container query (B2) — the block adapts to ITS OWN width, not the viewport, so
* it reflows the same whether it's full-bleed or dropped in a narrow sidebar.
* Under ~440px the header toolbar stacks its controls full-width (search, filters,
* actions each on their own row) instead of cramming one row; the table body keeps
* its horizontal scroll. (Named container so a nested block can't mis-trigger.) */
@container datatable (max-width: 440px) {
.datatable__bar .toolbar { flex-direction: column; align-items: stretch; }
.datatable__bar .toolbar__spacer { display: none; }
}
/* Header band — a .toolbar lives inside for the default controls; the band
* supplies the sunken surface + divider, and flips to the bulk bar on selection. */
.datatable__bar { display: flex; align-items: center; gap: var(--k-s-10); padding: var(--k-s-8) var(--k-s-12); border-bottom: var(--k-divider); background: var(--k-surface-sunken); font-size: var(--k-type-small); }
.datatable__bar--active { background: var(--k-state-selected-bg, var(--k-primary-soft)); color: var(--k-state-selected-fg, var(--k-fg)); }
.datatable__count { font-weight: var(--k-weight-semibold); }
.datatable__spacer { margin-left: auto; }
/* Scroll body — sticky thead stays pinned while rows scroll. Page-level tables
* paginate (~10-12 rows), so they grow to natural height and let the PAGE scroll
* (no cramped inner well). The 240px cap stays for the compact gallery tile. */
.datatable__body { max-height: 240px; overflow: auto; }
.datatable--page .datatable__body { max-height: none; overflow: visible; }
/* Narrow screens: a wide page table would crush its columns and clip the last
one. Keep a sane min width and scroll the body horizontally instead — the
universal mobile-table affordance (the toolbar stacks separately below). */
@container datatable (max-width: 40rem) {
.datatable--page .datatable__body { overflow-x: auto; }
.datatable--page .tbl { min-width: 34rem; }
/* When the body scrolls, a frozen first column keeps row identity in view.
The seam is scoped HERE (not on the base) so a full-width desktop table
doesn't grow a lone vertical divider on column one. */
.datatable--page .tbl__col--frozen { box-shadow: 1px 0 0 var(--k-border); }
/* The header cell also pins; stack its bottom divider WITH the seam (the
sticky-thead rule below would otherwise replace the seam with just the divider). */
.datatable--page .tbl thead .tbl__col--frozen { box-shadow: inset 0 -1px 0 var(--k-border), 1px 0 0 var(--k-border); }
}
.datatable .tbl thead th {
position: sticky; top: 0; z-index: 1;
background: var(--k-surface);
box-shadow: inset 0 -1px 0 0 var(--k-border);
}
.datatable .tbl td:first-child, .datatable .tbl th:first-child { padding-left: var(--k-s-12); }
.datatable .tbl td:last-child, .datatable .tbl th:last-child { padding-right: var(--k-s-12); }
/* Selection cell — checkbox column, fixed narrow width. */
.datatable__check { width: 36px; }
/* Content-stress helper: .truncate (single-line ellipsis clamp) is now defined
on the .tbl ATOM above, so every table — bare or inside .datatable — gets it,
same as the th.num / td.num numeric treatment. */
/* State slot — EMPTY / LOADING / ERROR share one centered well, so a row-less
* table reads as a deliberate state, not a broken layout. Drop it in a full-span
* <td> (or directly in the body) in place of <tbody> rows. */
.datatable__state { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--k-s-8); padding: var(--k-s-32) var(--k-s-16); text-align: center; color: var(--k-fg-muted); }
.datatable__state-icon { color: var(--k-fg-faint); line-height: 0; }
.datatable__state-title { font-weight: var(--k-weight-semibold); color: var(--k-fg); }
.datatable__state-msg { font-size: var(--k-type-small); max-width: 40ch; }
.datatable__state--error .datatable__state-icon { color: var(--k-danger); }
/* Loading — skeleton rows in <tbody> (pair each cell with a .sk bar); the body
* dims a touch so the table reads as "busy" rather than empty. */
.datatable--loading .datatable__body { opacity: 0.85; }
/* Footer band — row range, a rows-per-page .select, and the .pagination atom.
* Mirrors the header surface, sits below the body inside the frame. */
.datatable__foot { display: flex; align-items: center; gap: var(--k-s-12); flex-wrap: wrap; font-size: var(--k-type-small); color: var(--k-fg-muted); }
.datatable__foot-info { margin-right: auto; }
.datatable__perpage { display: inline-flex; align-items: center; gap: var(--k-s-8); white-space: nowrap; }
.datatable__perpage .select { width: auto; min-height: var(--k-control-h-sm); }