Theme
On this page (17)
Component Skill — components/table/component-skill.md

Native basis

<table> element with semantic <thead>, <tbody>, <tfoot>, and <caption>.

Web Platform APIs

<table><thead><tbody><tfoot><caption>

Classes

.table-container.table.table-caption.table-head.table-row.table-cell

Accessibility

• Use <th> with appropriate scope for column/row headers

• <caption> provides an accessible name for the table

• Screen readers announce table structure (rows, columns, headers)

§Basic Table

Standard table with header and body rows. Uses .badge for status and .avatar for user display.

Table with a summary footer row. The invoice numbers are row headers: th class="table-cell

§Empty state

A table with no rows keeps its header and shows one .table-empty cell spanning every column (colspan) - an icon, a line of text, an optional action - instead of an empty box.

§Sortable columns

A .table-sort button in a header makes the column sortable: click for ascending, again for descending, a third time for the authored order. aria-sort on the th draws the arrow and tells screen readers. Text sorts with Intl.Collator (numeric: item 2 before item 10); data-sort-value gives formatted cells a raw key (amounts, dates).

§Selectable rows

A leading .table-select column of checkboxes; the one in the header selects all - it shows the dash when only some rows are chosen. Shift+click selects a range. Selected rows carry aria-selected and a tint; every change fires table-select.

§Reorderable rows

A .table-handle grip column: drag a row by its grip - a line shows where it lands. From the keyboard, Alt+ArrowUp / Alt+ArrowDown moves the focused row. Every move fires table-reorder.

§Alignment

data-align on a th / td - start, center, end (logical: they flip in RTL); data-numeric right-aligns tabular figures so digits line up; data-valign='top' pins a cell to the top of a tall row.

§Locked columns and header

data-lock-start='2' keeps the checkbox and the region in view while the container scrolls sideways; data-lock-end='1' keeps the Actions dropdown (a .table-menu popover per row); data-sticky-header keeps the header while it scrolls down (the container has a max-height). Scroll both ways.

§Action column

A .table-actions cell holds the row's buttons, end-aligned and always visible. Mobile: on a narrow table (under 40rem - switch the device toolbar to Phone) the inline buttons (.table-actions-inline) give way to one ⋯ button (.table-actions-menu) opening a native popover menu (.table-menu) - Escape or a pick closes it. On touch screens every action is a 44px target.

§Quiet actions

data-actions='quiet' dims the action buttons until the row is hovered or focused - still visible and clickable; touch screens always show them at full strength.

§Text in cells

Ways to render text: a .table-sub second line, a .table-truncate single line with an ellipsis (full text in title), a .table-clamp of two lines, .table-mono for ids and code, .table-muted, plus any component - badges, avatars, links, progress.

§Data grid

Everything together: select, sort, reorder and act - a sortable column resets once you drag a row into a manual order.

§Right to left

Alignment, locked columns and the sort arrow are logical - in dir='rtl' start is the right edge.

§Density

Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.

§States

Named states via the shared State API, bound on every table.table:

  • default - as authored: the original row order, no sort, nothing selected
  • sorted - by config.column (index) and config.direction (ascending / descending)
  • selected - config.rows: row indices or 'all'

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/table-{state}.png.

Machine contract - verified against table.schema.json by bun run verify:

StateTypeValuesDefaultDescription
sortedbooleantrue, falsefalseRows sorted by a column (setState('sorted', { column, direction })).
selectedbooleantrue, falsefalseRows selected (setState('selected', { rows })).

§CSS view file

Styles for the table component. Uses design tokens for colors, spacing, and radius.

@layer components {
  .table-container {
    width: 100%;
    overflow-x: auto;
    border: 1px solid var(--border);
    border-radius: var(--radius-xl);
    container-type: inline-size;
  }
  .table {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.875rem;
    caption-side: bottom;
  }
  .table-caption {
    padding: 0.75rem 1rem;
    font-size: 0.8125rem;
    color: var(--muted-foreground);
    text-align: center;
  }
  .table-head {
    padding: 0.75rem 1rem;
    /* explicit: a th's UA default centers unless the table sets a non-initial alignment */
    text-align: start;
    font-weight: 500;
    color: var(--muted-foreground);
    background-color: var(--muted);
    white-space: nowrap;
    border-bottom: 1px solid var(--border);
    &:first-child {
      border-top-left-radius: var(--radius-lg);
    }
    &:last-child {
      border-top-right-radius: var(--radius-lg);
    }
  }
  .table-row {
    border-bottom: 1px solid var(--border);
    transition: background-color 150ms ease;
    &:last-child {
      border-bottom: none;
    }
    tbody &:hover {
      background-color: var(--muted);
    }
  }
  .table-cell {
    padding: 0.75rem 1rem;
    vertical-align: middle;
    color: var(--foreground);
    /* a row header (<th scope="row">) reads like a cell: start-aligned,
       not the UA's centered th */
    &:is(th) { text-align: start; font-weight: 500; }
  }
  tfoot .table-row {
    background-color: var(--muted);
    border-top: 1px solid var(--border);
    font-weight: 500;
  }
  tfoot .table-cell:first-child {
    border-bottom-left-radius: var(--radius-lg);
  }
  tfoot .table-cell:last-child {
    border-bottom-right-radius: var(--radius-lg);
  }
  /* -- Density ----------------------------------------------------
     data-density on the .table root scales cell/head/caption padding.
     Scale matches sizing.css (0.75 / 1 / 1.25): comfortable == the unsized
     default (0.75rem 1rem), compact 0.375rem 0.625rem, spacious 1rem 1.25rem.
     :where() gives these (0,2,0) - an explicit density always beats the
     automatic narrow-container compaction below. */
  .table:where([data-density="compact"]) {
    & .table-head, & .table-cell { padding: 0.375rem 0.625rem; }
    & .table-caption { padding: 0.5rem 0.625rem; }
  }
  .table:where([data-density="comfortable"]) {
    & .table-head, & .table-cell { padding: 0.75rem 1rem; }
    & .table-caption { padding: 0.75rem 1rem; }
  }
  .table:where([data-density="spacious"]) {
    & .table-head, & .table-cell { padding: 1rem 1.25rem; }
    & .table-caption { padding: 1rem 1.25rem; }
  }
  /* -- Alignment: data-align on a th / td (start / center / end);
     data-numeric: end-aligned tabular figures (amounts, counts) ---------- */
  :is(.table-head, .table-cell) {
    &[data-align="start"] { text-align: start; }
    &[data-align="center"] { text-align: center; }
    &[data-align="end"] { text-align: end; }
    &[data-numeric] { text-align: end; font-variant-numeric: tabular-nums; }
    &[data-valign="top"] { vertical-align: top; }
    &[data-nowrap] { white-space: nowrap; }
  }
  .table { text-align: start; }
  /* -- Sorting: a .table-sort button in the header; aria-sort on the th
     (ascending / descending) draws the arrow, table.js sorts the rows ---- */
  .table-sort {
    display: inline-flex;
    align-items: center;
    gap: 0.375rem;
    margin: -0.25rem -0.5rem;
    padding: 0.25rem 0.5rem;
    border: 0;
    border-radius: var(--radius-sm);
    background: none;
    color: inherit;
    font: inherit;
    cursor: pointer;
    &:hover { color: var(--foreground); background-color: color-mix(in oklch, var(--foreground) 6%, transparent); }
    &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
    /* the arrow: both halves muted while unsorted, one strong when sorted */
    &::after {
      content: '';
      width: 0.5rem;
      height: 0.75rem;
      flex-shrink: 0;
      background:
        conic-gradient(from 150deg at 50% 0, currentColor 60deg, transparent 0) top / 100% 40% no-repeat,
        conic-gradient(from -30deg at 50% 100%, currentColor 60deg, transparent 0) bottom / 100% 40% no-repeat;
      opacity: 0.35;
    }
    [data-align="end"] > &, [data-numeric] > & { flex-direction: row-reverse; }
  }
  .table-head[aria-sort="ascending"] .table-sort::after {
    opacity: 1;
    background: conic-gradient(from 150deg at 50% 0, currentColor 60deg, transparent 0) center / 100% 50% no-repeat;
  }
  .table-head[aria-sort="descending"] .table-sort::after {
    opacity: 1;
    background: conic-gradient(from -30deg at 50% 100%, currentColor 60deg, transparent 0) center / 100% 50% no-repeat;
  }
  .table-head[aria-sort]:not([aria-sort="none"]) { color: var(--foreground); }
  /* -- Selection: a leading .table-select cell with a checkbox (the header
     one selects all); a selected row is tinted -------------------------- */
  .table-select {
    width: 1%;
    padding-inline-end: 0 !important;
    & .checkbox { vertical-align: middle; }
  }
  .table-row[aria-selected="true"] {
    background-color: color-mix(in oklch, var(--primary) 7%, transparent);
    tbody &:hover { background-color: color-mix(in oklch, var(--primary) 11%, transparent); }
  }
  /* -- Reordering: a .table-handle cell with a grip button; the row being
     dragged fades, a line shows where it lands -------------------------- */
  .table-handle {
    width: 1%;
    padding-inline-end: 0 !important;
    color: var(--muted-foreground);
    & button {
      display: inline-grid;
      place-items: center;
      width: 1.5rem;
      height: 1.5rem;
      padding: 0;
      border: 0;
      border-radius: var(--radius-sm);
      background: none;
      color: inherit;
      cursor: grab;
      &:hover { color: var(--foreground); background-color: var(--accent); }
      &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
      & svg { width: 1rem; height: 1rem; }
    }
  }
  .table-row[data-dragging] { opacity: 0.4; }
  .table-row[data-drop="before"] { box-shadow: inset 0 2px 0 var(--primary); }
  .table-row[data-drop="after"] { box-shadow: inset 0 -2px 0 var(--primary); }
  /* -- Locked columns + sticky header --------------------------------------
     data-lock-start="1|2|3" on the .table keeps the first columns in view
     while the .table-container scrolls sideways (table.js measures their
     offsets into --table-lock-1 / -2); data-lock-end="1" the last column.
     data-sticky-header keeps the header row while the container scrolls
     vertically (give the container a max-height). */
  .table[data-lock-start] :is(.table-head, .table-cell),
  .table[data-lock-end] :is(.table-head, .table-cell) { background-clip: padding-box; }
  .table[data-lock-start] :is(tr > :nth-child(1)),
  .table:is([data-lock-start="2"], [data-lock-start="3"]) :is(tr > :nth-child(2)),
  .table[data-lock-start="3"] :is(tr > :nth-child(3)),
  .table[data-lock-end] :is(tr > :last-child) {
    position: sticky;
    z-index: 1;
    background-color: var(--_lock-bg, var(--background));
  }
  .table[data-lock-start] tr > :nth-child(1) { inset-inline-start: 0; }
  .table:is([data-lock-start="2"], [data-lock-start="3"]) tr > :nth-child(2) { inset-inline-start: var(--table-lock-1, 0px); }
  .table[data-lock-start="3"] tr > :nth-child(3) { inset-inline-start: calc(var(--table-lock-1, 0px) + var(--table-lock-2, 0px)); }
  .table[data-lock-end] tr > :last-child { inset-inline-end: 0; }
  .table thead .table-head { --_lock-bg: var(--muted); }
  .table tfoot .table-cell { --_lock-bg: var(--muted); }
  /* the edge of the locked block casts a hairline */
  .table[data-lock-start="1"] tr > :nth-child(1),
  .table[data-lock-start="2"] tr > :nth-child(2),
  .table[data-lock-start="3"] tr > :nth-child(3) { box-shadow: inset -1px 0 0 var(--border); }
  .table[data-lock-end] tr > :last-child { box-shadow: inset 1px 0 0 var(--border); }
  .table-row:hover > :is(.table-cell) { --_lock-bg: color-mix(in oklch, var(--muted) 100%, var(--background)); }
  .table-row[aria-selected="true"] > .table-cell { --_lock-bg: color-mix(in oklch, var(--primary) 7%, var(--background)); }
  .table[data-sticky-header] thead .table-head {
    position: sticky;
    top: 0;
    z-index: 2;
  }
  .table-container:has(> .table[data-sticky-header]) { overflow: auto; }
  /* -- Actions: a .table-actions cell of buttons, end-aligned - always
     visible. data-actions="quiet" on the table dims them until the row is
     hovered or focused (touch screens: full strength). On a narrow table
     (container < 40rem) the inline buttons (.table-actions-inline) give way
     to one "more" button (.table-actions-menu) opening a native popover
     menu (.table-menu) - one menu in the row, no JavaScript. Touch screens
     get 44px targets. */
  .table-actions {
    width: 1%;
    white-space: nowrap;
    text-align: end;
    & > * + *,
    & .table-actions-inline > * + * { margin-inline-start: 0.25rem; }
  }
  .table-actions-inline { display: inline-flex; align-items: center; }
  .table-actions-menu { display: none; }
  .table[data-actions="quiet"] :is(.table-actions > .btn, .table-actions-inline, .table-actions-menu > .btn) {
    opacity: 0.45;
    transition: opacity 150ms ease;
  }
  .table[data-actions="quiet"] .table-row:is(:hover, :focus-within) :is(.table-actions > .btn, .table-actions-inline, .table-actions-menu > .btn) { opacity: 1; }
  @media (hover: none) {
    .table[data-actions="quiet"] :is(.table-actions > .btn, .table-actions-inline, .table-actions-menu > .btn) { opacity: 1; }
  }
  @media (pointer: coarse) {
    .table-actions .btn[data-size^="icon"] { min-width: 2.75rem; min-height: 2.75rem; }
  }
  @container (max-width: 40rem) {
    .table-actions:has(.table-actions-menu) .table-actions-inline { display: none; }
    .table-actions-menu { display: inline-flex; }
  }
  /* the row menu: a popover anchored to its own "more" button */
  .table-row { anchor-scope: --table-row-menu; }
  .table-actions-menu > .btn { anchor-name: --table-row-menu; }
  .table-menu[popover] {
    position: fixed;
    position-anchor: --table-row-menu;
    inset: auto;
    top: anchor(bottom);
    right: anchor(right);
    position-try-fallbacks: flip-block;
    margin: 0.25rem 0 0;
    min-width: 10rem;
    padding: 0.25rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background-color: var(--popover);
    color: var(--popover-foreground);
    box-shadow: var(--shadow-lg);
    text-align: start;
    &:popover-open { display: grid; gap: 0.125rem; }
  }
  .table-menu-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
    min-height: 2.25rem;
    padding: 0.375rem 0.625rem;
    border: 0;
    border-radius: var(--radius-md);
    background: none;
    color: inherit;
    font: inherit;
    font-size: 0.875rem;
    text-align: start;
    text-decoration: none;
    cursor: pointer;
    &:hover, &:focus-visible { background-color: var(--accent); color: var(--accent-foreground); outline: none; }
    &[data-tone="destructive"] { color: var(--destructive); }
    & svg { width: 1rem; height: 1rem; }
  }
  @media (pointer: coarse) {
    .table-menu-item { min-height: 2.75rem; }
  }
  /* -- Empty state: one .table-empty cell spanning the columns ----------- */
  .table-empty {
    padding: 2.5rem 1rem !important;
    text-align: center;
    color: var(--muted-foreground);
    & > * { display: block; margin-inline: auto; }
    & > * + * { margin-top: 0.375rem; }
    & > strong { color: var(--foreground); font-weight: 600; }
    & > .btn { display: inline-flex; margin-top: 1rem; }
  }
  .table-empty-icon {
    display: grid !important;
    place-items: center;
    width: 2.75rem;
    height: 2.75rem;
    margin-bottom: 0.5rem;
    border-radius: 9999px;
    background-color: var(--muted);
    color: var(--muted-foreground);
    & svg { width: 1.25rem; height: 1.25rem; }
  }
  tbody .table-row:has(> .table-empty):hover { background-color: transparent; }
  /* a dropdown button straight in the actions cell anchors its .table-menu too */
  .table-actions > .btn[popovertarget] { anchor-name: --table-row-menu; }
  /* -- Text in cells ------------------------------------------------------- */
  .table-sub {
    display: block;
    margin-top: 0.125rem;
    font-size: 0.8125rem;
    color: var(--muted-foreground);
  }
  .table-truncate {
    display: block;
    max-width: var(--table-truncate, 14rem);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .table-clamp {
    display: -webkit-box;
    max-width: var(--table-clamp, 20rem);
    overflow: hidden;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
  }
  .table-mono {
    font-family: var(--font-mono);
    font-size: 0.8125rem;
    white-space: nowrap;
  }
  .table-muted { color: var(--muted-foreground); }
  /* -- Container query: compact table in narrow containers -- */
  @container (max-width: 480px) {
    .table-head,
    .table-cell { padding: 0.5rem 0.75rem; font-size: 0.8125rem; }
  }
}
/* Accessibility: suppress motion for users who request it (REQUIRED for all
   components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of
   `none` keeps transitionend/animationend (and discrete display flips)
   firing so JS state machines that await them keep working. */
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .table,
    .table *,
    .table::before,
    .table::after,
    .table *::before,
    .table *::after,
    .table::backdrop,
    .table-caption,
    .table-caption *,
    .table-caption::before,
    .table-caption::after,
    .table-caption *::before,
    .table-caption *::after,
    .table-caption::backdrop,
    .table-cell,
    .table-cell *,
    .table-cell::before,
    .table-cell::after,
    .table-cell *::before,
    .table-cell *::after,
    .table-cell::backdrop,
    .table-container,
    .table-container *,
    .table-container::before,
    .table-container::after,
    .table-container *::before,
    .table-container *::after,
    .table-container::backdrop,
    .table-head,
    .table-head *,
    .table-head::before,
    .table-head::after,
    .table-head *::before,
    .table-head *::after,
    .table-head::backdrop,
    .table-row,
    .table-row *,
    .table-row::before,
    .table-row::after,
    .table-row *::before,
    .table-row *::after,
    .table-row::backdrop {
      transition-duration: 0.01ms !important;
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
    }
  }
}

§JS view file

/* -- Table component --------------------------------------------- */
// The table is CSS; this module adds what a data table does on top: column
// sorting (a .table-sort button in the header, aria-sort, Intl.Collator
// numeric order), row selection (a .table-select checkbox column with a
// select-all header box - indeterminate when some are chosen, Shift+click
// for ranges), row reordering (a .table-handle grip, native Drag and Drop,
// Alt+ArrowUp / Alt+ArrowDown), the offsets of locked columns
// (data-lock-start) and the named State API (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals } from '../../shared/state-api.js';
const df$ = defussGlobals();
/** default = as authored (original order, no sort, nothing selected);
 * sorted = { column, direction }; selected = { rows: [indices] | 'all' }. */
const tableStates = ['default', 'sorted', 'selected'];
const bodyOf = (table) => table.tBodies[0];
const bodyRows = (table) => [...(bodyOf(table)?.rows ?? [])];
const rowBox = (row) => row.querySelector(':scope > .table-select input[type="checkbox"]');
const headBox = (table) => table.tHead?.querySelector('.table-select input[type="checkbox"]');
/* -- Sorting --------------------------------------------------------------- */
function cellValue(row, col) {
  const cell = row.cells[col];
  if (!cell) return '';
  return cell.dataset.sortValue ?? cell.textContent.trim();
}
function sortBy(table, col, direction) {
  const body = bodyOf(table);
  if (!body) return;
  const lang = table.closest('[lang]')?.lang || undefined;
  const collator = new Intl.Collator(lang, { numeric: true, sensitivity: 'base' });
  const dir = direction === 'descending' ? -1 : 1;
  const rows = bodyRows(table);
  rows.sort((a, b) => {
    const x = cellValue(a, col);
    const y = cellValue(b, col);
    const nx = Number(x);
    const ny = Number(y);
    const c = x !== '' && y !== '' && Number.isFinite(nx) && Number.isFinite(ny) ? nx - ny : collator.compare(x, y);
    return c * dir;
  });
  body.append(...rows);
  [...(table.tHead?.rows[0]?.cells ?? [])].forEach((th, i) => {
    if (th.querySelector('.table-sort')) th.setAttribute('aria-sort', i === col ? direction : 'none');
  });
  table._sort = { column: col, direction };
}
function unsort(table) {
  const body = bodyOf(table);
  if (body && table._original) body.append(...table._original.filter((r) => r.parentElement === body));
  table.tHead?.querySelectorAll('[aria-sort]').forEach((th) => th.setAttribute('aria-sort', 'none'));
  table._sort = null;
}
/* -- Selection ------------------------------------------------------------- */
function syncSelection(table, announce = true) {
  const rows = bodyRows(table);
  const boxes = rows.map(rowBox).filter(Boolean);
  rows.forEach((row) => {
    const box = rowBox(row);
    if (box) row.setAttribute('aria-selected', String(box.checked));
  });
  const head = headBox(table);
  if (head) {
    const on = boxes.filter((b) => b.checked).length;
    head.checked = boxes.length > 0 && on === boxes.length;
    head.indeterminate = on > 0 && on < boxes.length;
  }
  if (announce) {
    const selected = rows.filter((r) => r.getAttribute('aria-selected') === 'true');
    table.dispatchEvent(new CustomEvent('table-select', { bubbles: true, detail: { rows: selected, count: selected.length } }));
  }
}
function selectRows(table, which) {
  const rows = bodyRows(table);
  rows.forEach((row, i) => {
    const on = which === 'all' || (Array.isArray(which) && which.includes(i));
    const box = rowBox(row);
    if (box) box.checked = on;
    row.setAttribute('aria-selected', String(on));
  });
  syncSelection(table);
}
/* -- Reordering ------------------------------------------------------------ */
function announceMove(table, row) {
  table.dispatchEvent(new CustomEvent('table-reorder', { bubbles: true, detail: { row, index: bodyRows(table).indexOf(row) } }));
}
function moved(table, row) {
  // a manual order is no longer the sorted one
  table.tHead?.querySelectorAll('[aria-sort]').forEach((th) => th.setAttribute('aria-sort', 'none'));
  table._sort = null;
  announceMove(table, row);
}
function initReorder(table) {
  let dragged = null;
  const clear = () => table.querySelectorAll('[data-drop]').forEach((r) => r.removeAttribute('data-drop'));
  // a row drags only from its grip - text in the other cells stays selectable
  table.addEventListener('pointerdown', (e) => {
    const handle = e.target.closest?.('.table-handle');
    if (handle) handle.closest('tr').draggable = true;
  });
  table.addEventListener('dragstart', (e) => {
    const row = e.target.closest?.('tbody > tr');
    if (!row || !row.draggable) return;
    dragged = row;
    row.dataset.dragging = '';
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', row.cells[1]?.textContent.trim() ?? '');
  });
  table.addEventListener('dragover', (e) => {
    const row = e.target.closest?.('tbody > tr');
    if (!dragged || !row || row === dragged) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    const r = row.getBoundingClientRect();
    const where = e.clientY - r.top < r.height / 2 ? 'before' : 'after';
    if (row.dataset.drop !== where) {
      clear();
      row.dataset.drop = where;
    }
  });
  table.addEventListener('drop', (e) => {
    const row = table.querySelector('tbody > tr[data-drop]');
    if (!dragged || !row) return;
    e.preventDefault();
    row.parentElement.insertBefore(dragged, row.dataset.drop === 'before' ? row : row.nextSibling);
    clear();
    moved(table, dragged);
  });
  table.addEventListener('dragend', () => {
    if (dragged) {
      delete dragged.dataset.dragging;
      dragged.draggable = false;
    }
    dragged = null;
    clear();
  });
  // keyboard: Alt+ArrowUp / Alt+ArrowDown on anything in the row
  table.addEventListener('keydown', (e) => {
    if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return;
    const row = e.target.closest?.('tbody > tr');
    if (!row) return;
    e.preventDefault();
    const sib = e.key === 'ArrowUp' ? row.previousElementSibling : row.nextElementSibling;
    if (!sib) return;
    row.parentElement.insertBefore(row, e.key === 'ArrowUp' ? sib : sib.nextSibling);
    e.target.focus();
    moved(table, row);
  });
}
/* -- Locked columns: offsets of the 2nd / 3rd locked column ------------------ */
function measureLocks(table) {
  const n = parseInt(table.dataset.lockStart || '0', 10);
  if (n < 2) return;
  const first = table.rows[0];
  if (!first) return;
  for (let i = 1; i < n; i++) table.style.setProperty(`--table-lock-${i}`, `${first.cells[i - 1]?.getBoundingClientRect().width ?? 0}px`);
}
/* -- State API --------------------------------------------------------------- */
function triggerStateChange(table, stateName, config) {
  switch (stateName) {
    case 'default':
      unsort(table);
      selectRows(table, []);
      break;
    case 'sorted':
      sortBy(table, config?.column ?? 0, config?.direction === 'descending' ? 'descending' : 'ascending');
      break;
    case 'selected':
      selectRows(table, config?.rows ?? [0]);
      break;
  }
}
/** Registry-level API; pass the table explicitly. Unknown names throw. */
export const tableApi = {
  setState(table, stateName, config = {}) {
    if (!tableStates.includes(stateName)) {
      throw new Error(`table: unknown state "${stateName}" (supported: ${tableStates.join(', ')})`);
    }
    triggerStateChange(table, stateName, config);
    table.dataset.stateName = stateName;
    table._stateConfig = config;
  },
  getState(table) {
    const selected = bodyRows(table).flatMap((r, i) => (r.getAttribute('aria-selected') === 'true' ? [i] : []));
    return { name: table.dataset.stateName || 'default', config: { ...table._stateConfig, sort: table._sort ?? null, selected } };
  },
};
df$.tableApi = tableApi;
df$.tableStates = tableStates;
function init() {
  document.querySelectorAll('table.table:not([data-init])').forEach((table) => {
    table.dataset.init = '';
    table.dataset.stateName = 'default';
    table._original = bodyRows(table);
    table._sort = null;
    table.api = {
      setState: (stateName, config) => tableApi.setState(table, stateName, config),
      getState: () => tableApi.getState(table),
    };
    // sorting: a click on a .table-sort button cycles ascending → descending → as authored
    table.tHead?.querySelectorAll('.table-sort').forEach((btn) => {
      const th = btn.closest('th');
      if (!th.hasAttribute('aria-sort')) th.setAttribute('aria-sort', 'none');
      btn.addEventListener('click', () => {
        const col = th.cellIndex;
        const now = th.getAttribute('aria-sort');
        const next = now === 'ascending' ? 'descending' : now === 'descending' ? 'none' : 'ascending';
        if (next === 'none') unsort(table);
        else sortBy(table, col, next);
        table.dataset.stateName = next === 'none' ? 'default' : 'sorted';
        table.dispatchEvent(new CustomEvent('table-sort', { bubbles: true, detail: { column: col, direction: next } }));
      });
    });
    // an authored aria-sort sorts on load
    const pre = table.tHead?.querySelector('th[aria-sort="ascending"], th[aria-sort="descending"]');
    if (pre) sortBy(table, pre.cellIndex, pre.getAttribute('aria-sort'));
    // selection
    if (table.querySelector('.table-select input[type="checkbox"]')) {
      let last = null;
      table.addEventListener('click', (e) => {
        const box = e.target.closest?.('.table-select input[type="checkbox"]');
        if (!box) return;
        if (box === headBox(table)) {
          const on = box.checked;
          bodyRows(table).forEach((r) => { const b = rowBox(r); if (b && !b.disabled) b.checked = on; });
        } else {
          // Shift+click: the range from the last clicked row takes this state
          const rows = bodyRows(table);
          const row = box.closest('tr');
          if (e.shiftKey && last && rows.includes(last)) {
            const [a, b] = [rows.indexOf(last), rows.indexOf(row)].sort((x, y) => x - y);
            rows.slice(a, b + 1).forEach((r) => { const rb = rowBox(r); if (rb && !rb.disabled) rb.checked = box.checked; });
          }
          last = row;
        }
        syncSelection(table);
        table.dataset.stateName = bodyRows(table).some((r) => r.getAttribute('aria-selected') === 'true') ? 'selected' : 'default';
      });
      syncSelection(table, false);
    }
    if (table.querySelector('.table-handle')) initReorder(table);
    if (table.dataset.lockStart) {
      measureLocks(table);
      // measured on the next frame: re-pinning the locked columns inside the
      // observer would change layout mid-delivery (the "ResizeObserver loop")
      let frame = 0;
      new ResizeObserver(() => {
        cancelAnimationFrame(frame);
        frame = requestAnimationFrame(() => measureLocks(table));
      }).observe(table);
    }
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub