Theme
Design your own
On this page (18)
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 (a plain <a> in a cell takes the text colour with a soft underline, not the browser's blue), 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 })).

§API

Generated from table.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type TableState = 'default' | 'sorted' | 'selected' - setState(name, config) takes the config of the state it names.

StateDescription
default
As authored: the original row order, no sort, nothing selected.
Config fieldTypeDescription
sort?{ column: number; direction: 'ascending' | 'descending' } | nullreported by getState(): the sort applied, null for none
selected?number[]reported by getState(): the indices of the selected body rows
sorted
Sorted by one column.
Config fieldTypeDescription
column?numberthe column's index (default 0)
direction?'ascending' | 'descending'the direction (default ascending)
sort?{ column: number; direction: 'ascending' | 'descending' } | nullthe sort as getState() reports it - accepted instead of column / direction
selected?number[]body rows to select as well, by index
selected
Rows selected.
Config fieldTypeDescription
rows?number[] | 'all'the body rows to select: indices, or 'all' (default [0])
selected?number[]the same as rows (what getState() reports)
sort?{ column: number; direction: 'ascending' | 'descending' } | nulla sort to keep while selecting

Every element

MemberDescription
el.api.setState<S extends TableState>(name: S, config?: TableStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?TableStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: TableState; config: TableStateConfigs[TableState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: TableState; config: TableStateConfigs[TableState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: TableState; config: TableStateConfigs[TableState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: TableState; config: TableStateConfigs[TableState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: TableState; config: TableStateConfigs[TableState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.tableApi.setState<S extends TableState>(el: HTMLElement, name: S, config?: TableStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?TableStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.tableApi.getState(el: HTMLElement): { name: TableState; config: TableStateConfigs[TableState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: TableState; config: TableStateConfigs[TableState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.tableApi.render(state: { name: TableState; config: TableStateConfigs[TableState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: TableState; config: TableStateConfigs[TableState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.tableApi.store(el: HTMLElement): Store<{ name: TableState; config: TableStateConfigs[TableState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: TableState; config: TableStateConfigs[TableState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.tableApi.commit<S extends TableState>(el: HTMLElement, name: S, config?: TableStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?TableStateConfigs[S]its config
df$.shadcn.tableStates: TableState[]The declared states, 'default' first: default, sorted, selected.

Events

EventDescription
table-reorder
Fires after a row is moved (drag or keyboard) - the row and its new index.

detail: TableReorderDetail

FieldTypeDescription
rowHTMLTableRowElementthe row that moved
indexnumberits index among the body rows now
table-select
Fires when the selection changes - the selected rows and how many.

detail: TableSelectDetail

FieldTypeDescription
rowsHTMLTableRowElement[]the selected body rows, in table order
countnumberhow many
table-sort
Fires when a column is sorted - the column and the direction (ascending, descending, none).

detail: TableSortDetail

FieldTypeDescription
columnnumberthe sorted column's index (its header cell's cellIndex)
direction'ascending' | 'descending' | 'none'the new direction - 'none' restores the authored order

Types

TypeDescription
TableReorderDetail
What table-reorder carries.
FieldTypeDescription
rowHTMLTableRowElementthe row that moved
indexnumberits index among the body rows now
TableSelectDetail
What table-select carries.
FieldTypeDescription
rowsHTMLTableRowElement[]the selected body rows, in table order
countnumberhow many
TableSortDetail
What table-sort carries.
FieldTypeDescription
columnnumberthe sorted column's index (its header cell's cellIndex)
direction'ascending' | 'descending' | 'none'the new direction - 'none' restores the authored order

§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 ------------------------------------------------------- */
  /* a plain link in a cell (a name, an id) speaks the tokens, not the UA's
     blue: the text colour, a soft underline that firms up on hover, the
     ring on keyboard focus. Classed links (.btn, a badge) keep their own. */
  .table-cell a:not([class]) {
    color: var(--foreground);
    font-weight: 500;
    text-decoration: underline;
    text-decoration-color: color-mix(in oklch, currentColor 30%, transparent);
    text-underline-offset: 0.2em;
    transition: text-decoration-color 150ms ease;
    &:hover {
      text-decoration-color: currentColor;
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
      border-radius: var(--radius-sm);
    }
  }
  .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, defussQuery, componentState, bindComponent, textLocale } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
/** What table-select carries. */
interface TableSelectDetail {
  /** the selected body rows, in table order */
  rows: HTMLTableRowElement[];
  /** how many */
  count: number;
}
/** What table-reorder carries. */
interface TableReorderDetail {
  /** the row that moved */
  row: HTMLTableRowElement;
  /** its index among the body rows now */
  index: number;
}
/** What table-sort carries. */
interface TableSortDetail {
  /** the sorted column's index (its header cell's cellIndex) */
  column: number;
  /** the new direction - 'none' restores the authored order */
  direction: 'ascending' | 'descending' | 'none';
}
/** default = as authored (original order, no sort, nothing selected);
 * sorted = { column, direction }; selected = { rows: [indices] | 'all' }. */
const tableStates = ['default', 'sorted', 'selected'];
/** setState() configs per state (getState() reports the sort and the selected rows). */
export interface TableStateConfigs {
  /** As authored: the original row order, no sort, nothing selected. */
  default: {
    /** reported by getState(): the sort applied, null for none */
    sort?: { column: number; direction: 'ascending' | 'descending' } | null;
    /** reported by getState(): the indices of the selected body rows */
    selected?: number[];
  };
  /** Sorted by one column. */
  sorted: {
    /** the column's index (default 0) */
    column?: number;
    /** the direction (default ascending) */
    direction?: 'ascending' | 'descending';
    /** the sort as getState() reports it - accepted instead of column / direction */
    sort?: { column: number; direction: 'ascending' | 'descending' } | null;
    /** body rows to select as well, by index */
    selected?: number[];
  };
  /** Rows selected. */
  selected: {
    /** the body rows to select: indices, or 'all' (default [0]) */
    rows?: number[] | 'all';
    /** the same as rows (what getState() reports) */
    selected?: number[];
    /** a sort to keep while selecting */
    sort?: { column: number; direction: 'ascending' | 'descending' } | null;
  };
}
const bodyOf = (table) => table.tBodies[0];
const bodyRows = (table) => [...(bodyOf(table)?.rows ?? [])];
const rowBox = (row) => dfDollar(row).find<HTMLInputElement>(':scope > .table-select input[type="checkbox"]').get(0);
const headBox = (table) => dfDollar(table.tHead).find<HTMLInputElement>('.table-select input[type="checkbox"]').get(0);
/* -- Sorting --------------------------------------------------------------- */
/** A sortable column states its (lack of) sort: init's enhancement, which
 *  render() applies to its copy too. */
function enhanceHead(table) {
  dfDollar(table.tHead).find('.table-sort').each((_i, btn) => {
    const th = btn.closest('th');
    if (th && !th.hasAttribute('aria-sort')) th.setAttribute('aria-sort', 'none');
  });
}
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 = textLocale(table);
  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 (dfDollar(th).find('.table-sort').get(0)) 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));
  dfDollar(table.tHead).find('[aria-sort]').attr('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');
    // Fires when the selection changes - the selected rows and how many.
    table.dispatchEvent(new CustomEvent<TableSelectDetail>('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));
    // only a selectable row (it has a select box) carries aria-selected -
    // a plain table's rows have no selection state to announce
    const box = rowBox(row);
    if (!box) return;
    box.checked = on;
    row.setAttribute('aria-selected', String(on));
  });
  syncSelection(table);
}
/* -- Reordering ------------------------------------------------------------ */
function announceMove(table, row) {
  // Fires after a row is moved (drag or keyboard) - the row and its new index.
  table.dispatchEvent(new CustomEvent<TableReorderDetail>('table-reorder', { bubbles: true, detail: { row, index: bodyRows(table).indexOf(row) } }));
}
function moved(table, row) {
  // a manual order is no longer the sorted one
  dfDollar(table.tHead).find('[aria-sort]').attr('aria-sort', 'none');
  table._sort = null;
  announceMove(table, row);
}
function initReorder(table) {
  let dragged = null;
  const clear = () => dfDollar(table).find('[data-drop]').toArray().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 = dfDollar(table).find('tbody > tr[data-drop]').get(0);
    if (!dragged || !row) return;
    e.preventDefault();
    const ref = row.dataset.drop === 'before' ? row : row.nextSibling;
    if (ref) dfDollar(ref).before(dragged);
    else dfDollar(row.parentElement).append(dragged);
    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;
    const ref = e.key === 'ArrowUp' ? sib : sib.nextSibling;
    if (ref) dfDollar(ref).before(row);
    else dfDollar(row.parentElement).append(row);
    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': {
      // { column, direction } - or getState()'s { sort: { column, direction } }
      const sort = config?.sort ?? {};
      const direction = config?.direction ?? sort.direction;
      sortBy(table, config?.column ?? sort.column ?? 0, direction === 'descending' ? 'descending' : 'ascending');
      // a getState() config describes the whole state: its selection too
      if (Array.isArray(config?.selected)) selectRows(table, config.selected);
      break;
    }
    case 'selected':
      // a getState() config describes the whole state: its sort too
      if (config && 'sort' in config) {
        if (config.sort) sortBy(table, config.sort.column ?? 0, config.sort.direction === 'descending' ? 'descending' : 'ascending');
        else unsort(table);
      }
      // { rows } - or getState()'s { selected }
      selectRows(table, config?.rows ?? config?.selected ?? [0]);
      break;
  }
}
/** Registry-level API; pass the table explicitly. Unknown names throw. */
export const tableApi = componentState({
  component: 'table',
  states: tableStates,
  apply: (table, state) => triggerStateChange(table, state.name, state.config),
  read: (table, state) => {
    const selected = bodyRows(table).flatMap((r, i) => (r.getAttribute('aria-selected') === 'true' ? [i] : []));
    return { name: table.dataset.stateName || 'default', config: { ...state.config, sort: table._sort ?? null, selected } };
  },
  markup: (el, state) => {
      enhanceHead(el);
      triggerStateChange(el, state.name, state.config);
    },
});
df$.tableApi = tableApi;
df$.tableStates = tableStates;
function init() {
  dfDollar<HTMLTableElement>('table.table:not([data-init])').toArray().forEach((table) => {
    table.dataset.init = '';
    table.dataset.stateName = 'default';
    table._original = bodyRows(table);
    table._sort = null;
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(table, tableApi);
    // sorting: a click on a .table-sort button cycles ascending → descending → as authored
    enhanceHead(table);
    dfDollar(table.tHead).find('.table-sort').toArray().forEach((btn) => {
      const th = btn.closest('th');
      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';
        // Fires when a column is sorted - the column and the direction (ascending, descending, none).
        table.dispatchEvent(new CustomEvent<TableSortDetail>('table-sort', { bubbles: true, detail: { column: col, direction: next } }));
      });
    });
    // an authored aria-sort sorts on load
    const pre = dfDollar(table.tHead).find<HTMLTableCellElement>('th[aria-sort="ascending"], th[aria-sort="descending"]').get(0);
    if (pre) sortBy(table, pre.cellIndex, pre.getAttribute('aria-sort'));
    // selection
    if (dfDollar(table).find('.table-select input[type="checkbox"]').get(0)) {
      let last = null;
      table.addEventListener('click', (e) => {
        const box = (e.target as HTMLElement).closest?.<HTMLInputElement>('.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 (dfDollar(table).find('.table-handle').get(0)) 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