ComponentsTable

Table

atom

The base data table — header, rows, numeric alignment, hover and zebra.

System health
Status of your core services.
NameStatus
API Gateway Healthy
Auth service Degraded
CDN Maintenance
Database Healthy
Queue worker Healthy

Recipe CSS

This is the exact CSS your kit ships for Table — token-driven, so it re-themes with every knob. Get it (and the rest) from the configurator's Use kit panel.

/* === Table === */
.tbl { width: 100%; border-collapse: collapse; font-size: var(--k-type-body); }
.tbl th, .tbl td {
  /* Cell padding scales with density — Compact / Default / Comfortable via --k-space */
  padding: calc(var(--k-space, 10px) * 0.55) calc(var(--k-space, 10px) * 0.65);
  text-align: left;
}
.tbl thead { color: var(--k-fg-muted); font-weight: var(--k-weight-semibold); font-size: var(--k-type-eyebrow); text-transform: uppercase; letter-spacing: var(--k-track-eyebrow); }
.tbl tbody tr { border-top: var(--k-divider); }
.tbl tbody tr:hover { background: var(--k-state-hover); }
/* Press state (B1) — a clickable row gives a tactile "pressed" confirm on
 * :active (the stronger --k-state-press wash, a notch above hover). Harmless on
 * non-interactive tables: it only fires for the instant a row is held down. */
.tbl tbody tr:active { background: var(--k-state-press); }
/* Row selected (user checked a row / picked it) — follows the Selection
 * accent toggle: brand-soft when Brand, neutral surface when Neutral. */
.tbl tbody tr.is-selected,
.tbl tbody tr[aria-selected="true"] {
  background: var(--k-state-selected-bg, var(--k-primary-soft));
}
.tbl tbody tr.is-selected:hover,
.tbl tbody tr[aria-selected="true"]:hover {
  background: var(--k-state-selected-bg, var(--k-primary-soft));
  filter: brightness(0.98);
}

/* Sortable column header — chevrons indicate sort state */
.tbl th.is-sortable {
  cursor: pointer;
  user-select: none;
}
.tbl th.is-sortable:hover { color: var(--k-fg); }
.tbl th.is-sortable:focus-visible {
  outline: var(--k-focus-ring-width) solid var(--k-ring);
  outline-offset: -2px;
}
.tbl__sort {
  display: inline-flex;
  align-items: center;
  gap: var(--k-s-4);
}
.tbl__sort-chevron {
  display: inline-flex;
  color: var(--k-fg-faint);
  transition: color var(--k-dur-fast, 120ms) var(--k-ease, ease);
}
.is-sortable.is-active .tbl__sort-chevron { color: var(--k-primary); }

/* Two-line cell (B★5) — the canonical "primary name + secondary meta" stack
   inside a table cell (issue key · type · points, file · size, user · email).
   Was hand-rolled per-table with inline styles; now a kit pattern so the meta
   hierarchy is consistent: name = near-black --k-fg, meta = the FAINT tier (one
   step below muted) so the sub-line recedes cleanly behind the name. */
.tbl__name { font-weight: var(--k-weight-medium); color: var(--k-fg); }
.tbl__sub { font-size: var(--k-type-small); color: var(--k-fg-faint); margin-top: 1px; }
/* Numeric column (CP3 — the Attio/Linear "dense but calm" data signature). Value
   columns (money, counts, %) right-align and use TABULAR figures so digits line
   up in a column and the eye scans magnitudes straight down the stack — the
   pro-data-table move every left-aligned table misses. On the .tbl ATOM (was
   scoped to the .datatable block) so EVERY table earns it, not just the flagship.
   Apply the num class to BOTH the column's th and its td. */
.tbl th.num, .tbl td.num { text-align: right; font-variant-numeric: tabular-nums; }
/* Tables are restrained shadcn-style: per-row border-top (base .tbl) + hover,
   no zebra striping. The zebra modifier was dropped — the base row treatment
   already reads cleanly and zebra added visual noise to dense data.

   The data-table BLOCK (bordered frame + toolbar + selection + footer +
   empty/loading/error states) is its own segment — see the "Data table" recipe,
   which composes this .tbl atom. */

/* --- Grouped rows (Tailwind "with grouped rows") -------------------------
   A subheader <tr> that segments the body into labelled groups (by team, date,
   status…). Drop a .tbl__group row before each run of member rows; give its one
   full-span <td> the colSpan. Reads as a quiet sunken band — eyebrow type, no
   hover lift — so the groups structure the table without competing with data. */
.tbl tr.tbl__group > * {
  background: var(--k-surface-sunken);
  font-size: var(--k-type-eyebrow); text-transform: uppercase; letter-spacing: var(--k-track-eyebrow);
  font-weight: var(--k-weight-semibold); color: var(--k-fg-muted);
  padding-top: var(--k-s-6); padding-bottom: var(--k-s-6);
}
.tbl tr.tbl__group { border-top: var(--k-divider); }
.tbl tr.tbl__group:hover > * { background: var(--k-surface-sunken); }

/* --- Summary / total row (Tailwind "with summary row") -------------------
   A <tfoot> totals line: a heavier top rule sets it off from the body and the
   figures go semibold. Pair the number cells with .num so the totals align under
   their columns. No hover (tfoot is outside tbody's hover rule). */
.tbl tfoot td, .tbl tfoot th {
  border-top: 2px solid var(--k-border);
  font-weight: var(--k-weight-semibold); color: var(--k-fg);
}

/* --- Condensed density (Tailwind "condensed") ----------------------------
   A tighter row rhythm for data-dense tables (admin grids, financials). Still
   density-aware — the cell padding stays a fraction of --k-space, just a smaller
   one — and the type drops to the small tier. */
.tbl--condensed { font-size: var(--k-type-small); }
.tbl--condensed th, .tbl--condensed td { padding: calc(var(--k-space, 10px) * 0.32) calc(var(--k-space, 10px) * 0.5); }

/* --- Truncation utility (.truncate) — the TEXT-SLOT role's guarantee --------
   A GLOBAL single-line clamp for any long unbreakable value (name, path, title)
   so one text slot can't wrap and break the layout. Universal (not table-scoped)
   so every text-slot role earns it — see the Role Canvas (contracts.ts). Pair
   with a title attr for the full value; --k-col-max overrides the per-slot cap. */
.truncate { display: block; max-width: var(--k-col-max, 24ch); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* --- Card-framed table (.tbl--card) ---------------------------------------
   The bare .tbl uses border-collapse:collapse + per-row border-top — right for
   a flush list, but collapsed borders belong to the GRID, not the cell box, so
   they VANISH under sticky cells and bleed past a rounded clip (confirmed
   FF/WebKit). .tbl--card is the primitive for a STANDALONE sticky/rounded
   table: separate borders, dividers drawn as inset box-shadow (they paint on
   the cell box → travel with a sticky cell + clip to the radius), a rounded
   bordered frame, a sticky opaque header, and the last row dropping its divider
   so the grid meets the curve clean. (The .datatable BLOCK builds on the same
   idea; this is the atom-level version for a table used bare.) Wrap in a
   max-height + overflow:auto container to engage the sticky header on scroll. */
.tbl--card { border-collapse: separate; border-spacing: 0; border: 1px solid var(--k-border); border-radius: var(--k-radius-lg); overflow: hidden; }
.tbl--card thead th { position: sticky; top: 0; z-index: 1; background: var(--k-surface); box-shadow: inset 0 -1px 0 var(--k-border); }
.tbl--card tbody tr { border-top: 0; }
.tbl--card tbody tr + tr td, .tbl--card tbody tr + tr th { box-shadow: inset 0 1px 0 var(--k-border); }

/* --- Hit-target floor (Invariant I4) --------------------------------------
   An INTERACTIVE row (one holding a checkbox, button or link) must clear the
   WCAG-2.2 24px target even at Compact density. :has() floors ONLY those rows,
   so a dense read-only table stays tight — the guard never fights density where
   it isn't needed. */
.tbl tbody tr:has(input, button, a, [role="button"]) td { min-block-size: var(--k-hit-min); }

/* --- Frozen first column (.tbl__col--frozen) ------------------------------
   Spreadsheet-shaped wide tables: pin the first column so row identity (name /
   id) survives horizontal scroll. Mark the first th AND every first td with
   .tbl__col--frozen and wrap the table in an overflow-x:auto container. Needs
   .tbl--card (separate borders) so the sticky cell keeps its opaque background
   and dividers — collapsed borders vanish under a sticky cell. A one-sided
   shadow reveals that there's more to the right. */
.tbl__col--frozen { position: sticky; left: 0; z-index: 1; background: var(--k-surface); }
.tbl thead .tbl__col--frozen { z-index: 2; }
.tbl--card .tbl__col--frozen::after {
  content: ""; position: absolute; top: 0; right: 0; bottom: 0; width: var(--k-s-16);
  transform: translateX(100%); pointer-events: none;
  box-shadow: inset var(--k-s-12) 0 var(--k-s-8) calc(var(--k-s-10) * -1) rgb(0 0 0 / 0.12);
}

/* --- Responsive (Tailwind "hidden columns" + "stacked on mobile") --------
   Wrap a table in .tbl-responsive (a size container, so it adapts to its own
   width, not the viewport). Two levers:
     .tbl__col--optional  drop non-essential columns under a narrow host (mark
                          BOTH the th and every matching td).
     .tbl--stack          go further: below the stack breakpoint each row reflows
                          to a label/value card — the header is visually hidden and
                          each cell shows its column name from data-label. */
.tbl-responsive { container-type: inline-size; width: 100%; overflow-x: auto; }
@container (max-width: 34rem) {
  .tbl-responsive .tbl__col--optional { display: none; }
}
@container (max-width: 26rem) {
  .tbl-responsive .tbl--stack thead { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
  .tbl-responsive .tbl--stack tbody tr { display: block; border: 1px solid var(--k-border); border-radius: var(--k-radius-md); padding: var(--k-s-4); margin-bottom: var(--k-s-8); }
  .tbl-responsive .tbl--stack tbody tr:hover { background: transparent; }
  .tbl-responsive .tbl--stack tbody td { display: flex; align-items: center; justify-content: space-between; gap: var(--k-s-12); padding: var(--k-s-4) var(--k-s-6); text-align: right; border: 0; }
  .tbl-responsive .tbl--stack tbody td::before { content: attr(data-label); font-weight: var(--k-weight-semibold); color: var(--k-fg-muted); text-align: left; text-transform: none; letter-spacing: normal; }
  /* optional columns stay dropped in the stacked form too (higher specificity
     than the stacked-td display rule above, so they don't reappear as rows). */
  .tbl-responsive .tbl--stack .tbl__col--optional { display: none; }
}