ComponentsData Table

Data Table

section

The 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.
ServiceOwnerRequestsStatus
ai-routerAva1.820 Healthy
pricing-engine-internal-v2Jordan942 Healthy
edge-cacheCasey12.840 Degraded
payments-prodEliot0 Down
background-jobsKira388 Healthy
analytics-etlGreta5.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); }