jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

registry:ui · Display

table — frame-width laws, token paint, composition recipes

The figure frame is a named inline-size container, so every responsive decision reads the FRAME's width, never the viewport's. Wide (≥ 30rem): the scroll law — fit-content columns, native overflow-x, consumer cells opt into pinned columns with data-sticky=start|end behind a hairline fold mark. Narrow: the CodePen card law — thead folds away, td[data-label] renders a muted label with the value flushed right. Every color routes through the --jx-table-* locals. On top of the paint sits the RECIPE SUITE: the component owns only the frame, so sorting, filtering, selection, row actions, column visibility and pagination are page-owned state composed from the family — the shadcn data-table tutorial's layering in terminal paint, ending in the tasks table.

install — table

$npx jixoai-ui add table

https://ui.jixoai.com/r/table.json

zero-dependency; recipes add their own families

container queries on the frame data-sticky pin law data-label card law --jx-table-* color surface sort · filter · select · paginate recipes

overview

Overview

The figure frame is a named inline-size container, so every responsive decision reads the FRAME's width, never the viewport's. Two frame-width laws, consumer-owned semantics, token paint, and a recipe suite on top — the component owns only the frame.

table is the fleet's a11y-heavy surface, and its first law is who owns the semantics: the component renders only the figure frame — the real thead/tbody/tfoot/th/td/caption elements are authored by YOU in the children snippet, rows are never wrapped, and the caption prop names the table for assistive technology. On top of that, two frame-width laws run off a named inline-size container: at ≥ 30rem the scroll law (fit-content columns, native overflow-x, data-sticky pins behind a hairline fold, all logical properties so RTL mirrors free); below it the stack law — thead folds away and td[data-label] renders a muted label beside each value, the card law. stack={false} pins the scroll law at every width.

The paint is a token surface, not a palette: every color routes through the --jx-table-* locals, so style="--jx-table-hover: …" retunes one var per instance and the hover follows the site's brand hue at runtime. Density is the family's one opinion (the own is sm — dense tabular rows are the declared posture); the seven sibling axes are no-own supply for the cells. And the recipes below — sorting, filtering, selection, pagination, row actions, column visibility — are PAGE-OWNED STATE composed from the family: the component owns the frame, you own the decision.

table

A registry-consumer operations table — Consumer and Docs pin to the scrollport while Items/Coverage scroll under them; drag the frame width past 30rem and the same rows re-lay out into label:value cards. The hover tone control retunes --jx-table-hover live.

registry consumers — frame-width laws
ConsumerStatusItemsCoverageSinceRuntimeRegionDocs
unipty unipty.jixoai.com
live12 92%2025-11-04bun 1.2.19ap-southeast-1view
openspecui openspecui.com
live9 78%2026-01-17node 24.20.0eu-central-1view
ui.jixoai.com this site
live24 100%2026-08-30bun 1.2.19us-east-2view
jixoai/www internal
beta6 45%2026-08-12node 22.14.0cn-north-1view
agent-console internal
wip3 12%2026-07-03deno 2.3.5ap-northeast-1view
Total—54—————
table-usage.svelte svelte
<Table caption="registry consumers — frame-width laws">
  <thead>
    <tr>
      <th data-sticky="start" scope="col">Consumer</th>
      <th scope="col">Status</th>
      <th scope="col">Docs</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td data-sticky="start" data-label="Consumer">unipty</td>
      <td data-label="Status">live</td>
      <td data-label="Docs"><a href="…">view</a></td>
    </tr>
    <!-- …rows follow the same law: data-label stacks, data-sticky pins -->
  </tbody>
</Table>

<!-- color freedom: every paint routes through --jx-table-* locals.
     hover follows --brand-hue by default; one var retunes it: -->
<Table style="--jx-table-hover: color-mix(in oklab, var(--secondary) 12%, var(--background))">
  …
</Table>

usage

Usage

Author the native semantic set inside the frame — caption, thead/tbody/tfoot stay yours; data-label arms the narrow-frame card law, data-sticky pins columns under the scroll law.

table usage
<Table caption="registry consumers — frame-width laws">
  <thead>
    <tr>
      <th data-sticky="start" scope="col">Consumer</th>
      <th scope="col">Status</th>
      <th scope="col">Docs</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td data-sticky="start" data-label="Consumer">unipty</td>
      <td data-label="Status">live</td>
      <td data-label="Docs"><a href="…">view</a></td>
    </tr>
    <!-- …rows follow the same law: data-label stacks, data-sticky pins -->
  </tbody>
</Table>

<!-- color freedom: every paint routes through --jx-table-* locals.
     hover follows --brand-hue by default; one var retunes it: -->
<Table style="--jx-table-hover: color-mix(in oklab, var(--secondary) 12%, var(--background))">
  …
</Table>

examples

Examples

The composition recipes, one ability per demo: sortable headers, filter row + facets, pagination footer, row selection, row actions + column visibility, sticky header — composed into the tasks table at the end.

The component owns the frame; every interactive layer below is page-owned state over public component behavior (composition-first — zero registry edits). Missing atoms are recorded in the change's followups, never worked around silently.

with sortable headers

th[aria-sort] carries the tri-state (ascending | descending | absent) and a press-button caret announces the direction; ONE column sorts at a time and the third press returns the table to DOM order.

tasks — sortable headers
id
T-101harden registry install integrity in review high09-02
T-102mirror manifest re-record active medium09-04
T-103container-query card law done low08-27
T-104anchor lease restore backlog urgent09-09
T-105facet filter row in review medium09-01
T-106select-all indeterminate active high09-03
table-sortable-usage.svelte svelte
<script lang="ts">
  // single-column sort state: one key, one direction, three presses
  let sortKey = $state<string | null>(null);
  let sortDir = $state<'asc' | 'desc'>('asc');
  function toggleSort(key) {
    if (sortKey !== key) { sortKey = key; sortDir = 'asc'; return; }
    if (sortDir === 'asc') { sortDir = 'desc'; return; }
    sortKey = null; // third press clears → DOM order
  }
  const rows = $derived(sortKey === null
    ? tasks
    : [...tasks].sort((a, b) => (sortDir === 'asc' ? 1 : -1)
        * String(a[sortKey]).localeCompare(String(b[sortKey]))));
</script>

<Table caption="tasks" stack={false}>
  <thead>
    <tr>
      <th scope="col" aria-sort={sortKey === 'title'
        ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}>
        <PressButton variant="ghost" ariaLabel="sort by title" onclick={() => toggleSort('title')}>
          title <span aria-hidden="true">{sortKey === 'title' ? (sortDir === 'asc' ? '▲' : '▼') : '↕'}</span>
        </PressButton>
      </th>
      <!-- …one aria-sort per table, on the sorted column only -->
    </tr>
  </thead>
  <tbody>{#each rows as row (row.id)}<tr><td>{row.title}</td></tr>{/each}</tbody>
</Table>

with filter row and faceted filter

A second thead row mounts a control per filterable column: Input for text-contains, a multiple toggle-group as the status facet picker (checked facets OR together).

tasks — filter row
idtitlestatusdue
filters
T-101harden registry install integrity in review 09-02
T-102mirror manifest re-record active 09-04
T-103container-query card law done 08-27
T-104anchor lease restore backlog 09-09
T-105facet filter row in review 09-01
T-106select-all indeterminate active 09-03
table-filter-usage.svelte svelte
<script lang="ts">
  let titleFilter = $state('');
  let statusFacets = $state([]); // toggle-group multiple → string[]
  const rows = $derived(tasks.filter((row) =>
    (titleFilter === '' || row.title.toLowerCase().includes(titleFilter.toLowerCase())) &&
    (statusFacets.length === 0 || statusFacets.includes(row.status))));
</script>

<Table caption="tasks" stack={false}>
  <thead>
    <tr><!-- …the label row… --></tr>
    <tr><!-- the FILTER row: a control per filterable column -->
      <th scope="col"><Input type="search" bind:value={titleFilter} aria-label="filter by title" placeholder="contains…" /></th>
      <th scope="col">
        <ToggleGroup name="status-facets" type="multiple" label="filter by status" bind:value={statusFacets}>
          {#each statuses as status (status)}<ToggleGroupItem value={status}>{status}</ToggleGroupItem>{/each}
        </ToggleGroup>
      </th>
    </tr>
  </thead>
  <tbody>{#each rows as row (row.id)}<tr><td>{row.title}</td><td>{row.status}</td></tr>{/each}</tbody>
</Table>

The existing pagination family under the slice: pageRange windows the links, Previous/Next walk the bounds as honest buttons, and a page-size select re-slices everything.

tasks — pagination footer
idtitlestatus
T-101harden registry install integrity in review
T-102mirror manifest re-record active
T-103container-query card law done
T-104anchor lease restore backlog
T-105facet filter row in review
5 of 12 tasks
  • 5 rows
  • 8 rows
  • 12 rows

with row selection

A checkbox column with the select-all law: the header box is indeterminate on partial selection, presses to select-all when partial, clears when full; the count readout is a polite live region.

tasks — row selection
idtitle
T-101harden registry install integrity
T-102mirror manifest re-record
T-103container-query card law
T-104anchor lease restore
T-105facet filter row
T-106select-all indeterminate

0 of 6 selected

table-selection-usage.svelte svelte
<script lang="ts">
  let selected = $state(new Set());
  const pageIds = $derived(pageRows.map((row) => row.id));
  const all = $derived(pageIds.length > 0 && pageIds.every((id) => selected.has(id)));
  const some = $derived(pageIds.some((id) => selected.has(id)) && !all);
  function toggleRow(id) {
    const next = new Set(selected);
    next.has(id) ? next.delete(id) : next.add(id);
    selected = next;
  }
  function toggleAll() {
    const next = new Set(selected);
    if (all) pageIds.forEach((id) => next.delete(id));
    else pageIds.forEach((id) => next.add(id));
    selected = next;
  }
</script>

<Table caption="tasks" stack={false}>
  <thead>
    <tr>
      <th scope="col">
        <!-- the law: indeterminate is an IDL property — the Checkbox prop drives it -->
        <Checkbox checked={all} indeterminate={some} aria-label="select all rows on this page"
                  onchange={toggleAll} />
      </th>
      <!-- … -->
    </tr>
  </thead>
  <tbody>
    {#each pageRows as row (row.id)}
      <tr>
        <td><Checkbox checked={selected.has(row.id)} aria-label={'select ' + row.id}
                      onchange={() => toggleRow(row.id)} /></td>
        <!-- … -->
      </tr>
    {/each}
  </tbody>
</Table>
<p aria-live="polite">{selected.size} of {rows.length} selected</p>

with row actions and column visibility

An actions column of dropdown menus (copy id / duplicate / destructive delete) plus the toolbar's column-visibility popover — optional columns leave the DOM entirely, colspans follow.

last action: —
tasks — row actions
idtitlepriorityassigneeactions
T-101harden registry install integrityhigh@gaubee
T-102mirror manifest re-recordmedium@kzf
T-103container-query card lawlow@gaubee
T-104anchor lease restoreurgent@oss
T-105facet filter rowmedium@kzf
table-actions-usage.svelte svelte
<script lang="ts">
  let visible = $state({ priority: true, assignee: true });
  let echo = $state('—');
</script>

<!-- the toolbar: column visibility behind a popover of checkboxes -->
<Popover id="tasks-columns" triggerLabel="columns">
  <Checkbox label="priority" checked={visible.priority} onchange={() => (visible = { ...visible, priority: !visible.priority })} />
  <Checkbox label="assignee" checked={visible.assignee} onchange={() => (visible = { ...visible, assignee: !visible.assignee })} />
</Popover>

<Table caption="tasks" stack={false}>
  <thead>
    <tr>
      <th scope="col">title</th>
      {#if visible.priority}<th scope="col">priority</th>{/if}
      <th scope="col"><span class="sr-only">actions</span></th>
    </tr>
  </thead>
  <tbody>
    {#each rows as row (row.id)}
      <tr>
        <td>{row.title}</td>
        {#if visible.priority}<td>{row.priority}</td>{/if}
        <td>
          <DropdownMenu id={'task-menu-' + row.id} placement="bottom-end">
            {#snippet trigger()}
              <button type="button" popovertarget={'task-menu-' + row.id}
                      aria-label={'actions for ' + row.id} class="…">⋯</button>
            {/snippet}
            <DropdownMenuItem onclick={() => (echo = 'copy id · ' + row.id)}>Copy id</DropdownMenuItem>
            <DropdownMenuItem onclick={() => (echo = 'duplicate · ' + row.id)}>Duplicate</DropdownMenuItem>
            <hr />
            <DropdownMenuItem destructive onclick={() => (echo = 'delete · ' + row.id)}>Delete</DropdownMenuItem>
          </DropdownMenu>
        </td>
      </tr>
    {/each}
  </tbody>
</Table>

with sticky header

A tall event log under a clamped scrollport: the consumer owns the vertical law — max-block-size on the frame, thead th pinned to it. The cells already paint an opaque --jx-table-surface, so the pinned head masks scrolled rows.

event log — the head stays
wheneventlevel
08-26 · 10:42harden registry install integrityinfo
08-27 · 11:42mirror manifest re-recordwarn
08-28 · 12:42container-query card lawerror
08-29 · 13:42anchor lease restoreinfo
08-26 · 14:42facet filter rowwarn
08-27 · 15:42select-all indeterminateerror
08-28 · 16:42countdown ticksinfo
08-29 · 17:42pagination window mathwarn
08-26 · 18:42column visibility popovererror
08-27 · 19:42sticky scrollport lawinfo
08-28 · 20:42row actions dropdownwarn
08-29 · 21:42toolbar compositionerror
table-sticky-usage.svelte svelte
<!-- the consumer owns the vertical scrollport: clamp the FRAME
     (the figure) and stick the head to it — the cells already paint an
     opaque --jx-table-surface, so pinned heads mask scrolled rows.
     Recorded followup: table ships no built-in sticky-header law yet. -->
<Table class="sticky-scroll" caption="event log" stack={false}>
  <thead><!-- … --></thead>
  <tbody><!-- …enough rows to scroll… --></tbody>
</Table>

<style>
  :global(.sticky-scroll) { max-block-size: 16rem; overflow-y: auto; }
  :global(.sticky-scroll thead th) { position: sticky; top: 0; z-index: 1; }
</style>

the tasks table — everything composed

The terminal demo: toolbar (search + status facets + column visibility) over sortable headers, row selection with the select-all law, a row-actions column, and the pagination footer — one state machine, every recipe from this page.

tasks — the composed surface
idassigneeactions
T-101harden registry install integrity in review high@gaubee
T-102mirror manifest re-record active medium@kzf
T-103container-query card law done low@gaubee
T-104anchor lease restore backlog urgent@oss
T-105facet filter row in review medium@kzf
0 of 12 selected
  • 5 rows
  • 8 rows
  • 12 rows
table-tasks-usage.svelte svelte
<script lang="ts">
  // ONE state machine feeds every layer: filter → sort → slice → select
  let filter = $state('');                 // toolbar search
  let facets = $state([]);                 // toolbar status facets
  let visible = $state({ priority: true, assignee: true }); // column visibility
  let selected = $state(new Set());        // row selection
  let pageIndex = $state(0);               // pagination
  let pageSize = $state('5');
  let sortKey = $state(null); let sortDir = $state('asc');

  const filtered = $derived(tasks
    .filter((row) => (filter === '' || row.title.includes(filter))
      && (facets.length === 0 || facets.includes(row.status)))
    .toSorted((a, b) => sortKey === null ? 0
      : (sortDir === 'asc' ? 1 : -1) * String(a[sortKey]).localeCompare(String(b[sortKey]))));
  const pageCount = $derived(Math.max(1, Math.ceil(filtered.length / Number(pageSize))));
  const page = $derived(filtered.slice(pageIndex * Number(pageSize), (pageIndex + 1) * Number(pageSize)));
  const pageIds = $derived(page.map((row) => row.id));
  const all = $derived(pageIds.length > 0 && pageIds.every((id) => selected.has(id)));
  const some = $derived(pageIds.some((id) => selected.has(id)) && !all);
</script>

<!-- the toolbar: search + facets + column visibility -->
<div class="flex flex-wrap items-center gap-3">
  <Input type="search" bind:value={filter} aria-label="filter tasks by title" placeholder="filter tasks…" />
  <ToggleGroup name="tasks-status" type="multiple" label="filter by status" bind:value={facets}>
    {#each statuses as status (status)}<ToggleGroupItem value={status}>{status}</ToggleGroupItem>{/each}
  </ToggleGroup>
  <Popover id="tasks-columns" triggerLabel="columns">
    <Checkbox label="priority" checked={visible.priority}
      onchange={() => (visible = { ...visible, priority: !visible.priority })} />
    <Checkbox label="assignee" checked={visible.assignee}
      onchange={() => (visible = { ...visible, assignee: !visible.assignee })} />
  </Popover>
</div>

<Table caption="tasks — the composed surface" stack={false}>
  <thead>
    <tr>
      <th scope="col"><Checkbox checked={all} indeterminate={some}
        aria-label="select all rows on this page" onchange={toggleAll} /></th>
      <th scope="col" aria-sort={sortKey === 'title'
        ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}>
        <PressButton variant="ghost" ariaLabel="sort by title" onclick={() => toggleSort('title')}>
          title <span aria-hidden="true">{sortKey === 'title' ? (sortDir === 'asc' ? '▲' : '▼') : '↕'}</span>
        </PressButton>
      </th>
      <!-- …status / priority (visible.priority) / assignee (visible.assignee)… -->
      <th scope="col"><span class="sr-only">actions</span></th>
    </tr>
  </thead>
  <tbody>
    {#each page as row (row.id)}
      <tr>
        <td><Checkbox checked={selected.has(row.id)} aria-label={'select ' + row.id}
          onchange={() => toggleRow(row.id)} /></td>
        <td>{row.title}</td>
        {#if visible.priority}<td>{row.priority}</td>{/if}
        <td>
          <DropdownMenu id={'tasks-menu-' + row.id} placement="bottom-end">
            {#snippet trigger()}
              <button type="button" popovertarget={'tasks-menu-' + row.id}
                aria-label={'actions for ' + row.id}>⋯</button>
            {/snippet}
            <DropdownMenuItem onclick={() => act('copy id', row.id)}>Copy id</DropdownMenuItem>
            <DropdownMenuItem destructive onclick={() => act('archive', row.id)}>Archive</DropdownMenuItem>
          </DropdownMenu>
        </td>
      </tr>
    {/each}
  </tbody>
</Table>

<!-- the footer: selection readout + page-size + windowed pagination -->
<div class="flex items-center justify-between gap-3">
  <span aria-live="polite">{selected.size} of {tasks.length} selected</span>
  <Select options={[5, 8, 12].map((n) => ({ value: String(n), label: n + ' rows' }))}
    bind:value={pageSize} aria-label="rows per page" />
  <Pagination><!-- …pageRange window, Previous/Next buttons… --></Pagination>
</div>

table · semantic set

The untouched native baseline — caption, thead/tbody/tfoot authored by hand, numeric right-align through consumer classes. The same table now also carries data-label on every cell, so the narrow-frame card law needs zero extra markup.

jixoai components — environment support (2026-08)
ComponentSvelte 5Prerendered SSRFiles
press-buttonyesyes1
code-card + highlightyesyes2
tableyesyes1
tree-viewyesyes1
Total——5
table.svelte svelte
<!--
  jixoai table (registry/files/ui/table/table.svelte).

  2026-08-22 · responsive deepening (original request: 参考
  codepen.io/viki-code/pen/JjxGgmm 改进 table 组件;配色上需要有一定的
  自由度). The CodePen's container-query law lands here, translated onto
  the jixoai token sheet — the frame reads ITS OWN width, so the same
  table adapts inside any layout column, never the viewport's.

  Orthogonal intents:
  1. frame — the <figure> owns the hairline frame, the radius law
     (var(--radius): 0, bevel upgrade where supported) and BOTH responsive
     engines via `container: jx-table / inline-size` + native overflow-x.
  2. scroll law (frame ≥ 30rem) — table keeps min-width: fit-content so
     columns never compress; the frame scrolls natively. Consumer cells
     opt into pinned columns with data-sticky="start" | "end" on the th
     AND its td's: they stick to the frame scrollport behind a hairline
     fold mark. All pinning geometry is LOGICAL (inset-inline-*, border-
     inline-*) so RTL mirrors for free. Requires border-collapse:
     separate (collapsed borders tear under sticky in some engines) —
     rendering is identical here because the language carries only
     horizontal hairlines.
  3. stack law (frame < 30rem, the CodePen card mode) — thead folds
     away, each row becomes a card: td[data-label] renders a muted
     label ::before with the value flushed right; the first cell takes
     the head surface as the card head. stack={false} pins the table
     to the scroll law at every width (data-stack="off").
  4. color freedom — every paint routes through the --jx-table-* local
     token surface (arbitrary-property utilities below, theme-token
     defaults); hover already carries the --brand-hue flow (one number
     recolors it). Override per instance: <Table style="--jx-table-hover: …">.
  5. semantics — thead/tbody/tfoot/th/td/caption stay real elements
     authored by the consumer as the children snippet; rows are never
     wrapped, and numeric alignment stays a consumer class.

  Zero dependencies. td/th paint an opaque --jx-table-surface so sticky
  cells mask the content scrolling under them (transparent cells would
  leak it). Dense mode survives both laws.

  tw4 (2026-08-24): the frame paint, the local token surface, the
  container declaration and the table/caption statics ride utilities in
  the markup; EVERYTHING that reaches the consumer-authored descendants
  (:global surface), the @container engines and the state machines
  stays in table.css — D1-exempt residue.
-->
<script lang="ts">
  import type { Snippet } from 'svelte';
  import { getDensityContext, provideDensity, resolveDensity } from '$lib/density.svelte';
  import {
    densityRungOf,
    provideQueryAnchor,
    provideUniversalLanes,
    stampCarriersForLanes,
    type ColorLane,
    type DensityLane,
    type ElevationLane,
    type MotionLane,
    type QueryResult,
    type RadiusLane,
    type ShapeLane,
    type SizeLane,
    type ThemeLane,
  } from '$lib/defaults.svelte';
  import { TableDefaults } from './table-defaults.svelte';
  import { tableStyles } from './table.stylex';
  import './table.css';

  // the payload's own join (the separator serialize law): every
  // stylex.create member is an OBJECT in dev and the joined string in
  // shipped payloads — composition goes through THIS joiner (all
  // string values except $$css, space-joined).
  const cx = (
    ...styles: ({ readonly [key: string]: string | object } | undefined | string)[]
  ): string =>
    styles
      .filter(Boolean)
      .map((style) =>
        typeof style === 'string'
          ? style
          : Object.entries(style ?? {}).flatMap(([key, value]) =>
              key !== '$$css' && typeof value === 'string' ? [value] : [],
            ).join(' '),
      )
      .join(' ');

  interface Props {
    /** Density policy root: the universal §4 lane (named rungs + the
     *  documented small/medium/large aliases · auto · a coefficient
     *  number · query()) — explicit ?? inherited ?? sm. */
    density?: DensityLane | QueryResult<DensityLane>;
    /** Native caption element — renders as the table title. */
    caption?: string;
    /** Compact row height (0.4rem vertical padding instead of 0.75rem). */
    dense?: boolean;
    /**
     * Fold into card rows when the frame is narrower than 30rem
     * (default). false = keep the scroll law at every width.
     */
    stack?: boolean;
    /** Native thead/tbody/tfoot markup. */
    children: Snippet;
    /** universal size axis (§1): root font-size — named steps · auto
     *  (inherit) · a px number · query() */
    size?: SizeLane | QueryResult<SizeLane>;
    /** universal shape axis (§2): corner geometry; auto = inherit */
    shape?: ShapeLane | QueryResult<ShapeLane>;
    /** universal radius axis (§3): corner size; auto = the concentric
     *  broadcast (the frame is the family's concentric anchor) */
    radius?: RadiusLane | QueryResult<RadiusLane>;
    /** universal color axis (§5): the hue axis of the oklch system */
    color?: ColorLane | QueryResult<ColorLane>;
    /** universal theme axis (§6): light/dark/system; auto = tree
     *  inheritance (the .dark class bridge) */
    theme?: ThemeLane | QueryResult<ThemeLane>;
    /** universal elevation axis (§7): official M3 levels · dp ·
     *  query() */
    elevation?: ElevationLane | QueryResult<ElevationLane>;
    /** universal motion axis (§8): intensity — reduced…expressive ·
     *  a coefficient · query() */
    motion?: MotionLane | QueryResult<MotionLane>;
    class?: string;
    /**
     * Style passthrough landing on the frame — the color-freedom seam:
     * <Table style="--jx-table-hover: …"> retunes one var per instance.
     */
    style?: string;
  }

  let {
    density,
    caption = '',
    dense = false,
    stack = true,
    children,
    size,
    shape,
    radius,
    color,
    theme,
    elevation,
    motion,
    class: className = '',
    style: styleAttribute = '',
  }: Props = $props();

  // ---- the density lane: inherit-then-provide, boundary-legal ------
  // The CAPTURE is load-bearing and EAGER (r11 first contract,
  // context-defaults-economy 3.3 — the TODO-5 bare read this replaces):
  // getDensityContext() rides the $derived.by ARGUMENT subtree, which
  // evaluates at this statement — BEFORE provideDensity writes the key —
  // so it captures the PARENT's context object; a lazily-evaluated read
  // would resolve the key to the table's OWN write and self-reference
  // through the very getter it feeds (derived_references_self). The
  // family own 'sm' lives in TableDefaults (the design-frozen local
  // fallback migration — resolveDensity's third argument is retired).
  // The W3 universal lane narrows at the legacy edge (the input-group
  // law): 'auto'/number/query lanes carry no legacy rung — the rung
  // stays ambient (§4), the coefficient rides the carriers on the root
  const legacyDensityLane = $derived(
    typeof density === 'string' && density !== 'auto' ? density : undefined,
  );
  const resolvedDensity = $derived.by(
    ((inherited) => () => resolveDensity(legacyDensityLane, inherited))(getDensityContext()),
  );
  provideDensity(() => resolvedDensity);

  // THE DEFAULTS READ POINT (context-defaults-economy 3.3 + W3-D3),
  // riding ON TOP of the provider lane as the family's single audited
  // read point: the density slot's ambient read resolves the key to
  // the table's own write, whose getter is the captured-parent
  // resolution above, so the chain TERMINATES; the slot's own 'sm' is
  // the floor (explicit → inherited → 'sm', exactly the retired inline
  // fallback); the seven sibling axes ride the same record (the frame
  // is the family's own DOM root — the a11y semantics of the
  // consumer-authored thead/tbody stay untouched). PROVIDER-SNAPSHOT
  // KERNEL LAW: density does NOT ride the provideUniversalLanes
  // literal — the reactive bridged write above carries the universal
  // density supply; the literal carries the other seven axes
  const d = $derived(TableDefaults.resolve({ density, size, shape, radius, color, theme, elevation, motion }));
  const carriers = $derived(stampCarriersForLanes(d));
  provideUniversalLanes({ size, shape, radius, color, theme, elevation, motion });
  let uniRoot = $state<HTMLElement>();
  provideQueryAnchor(() => uniRoot ?? null);
  // §3/§14 radius consumption — the frame's dialect: an explicit lane
  // lands flat on the corner (effective × factor); auto stamps NOTHING
  // so the atom's var fallback keeps the frame's own site-radius
  // default (unlike the sheet's 0px invariants, this surface owns a
  // nonzero resting corner — the concentric inset form belongs to
  // nested surfaces, and the frame IS the border bearer)
  const radiusConsumed = $derived(
    d.radius !== undefined && d.radius !== 'auto'
      ? '--jx-radius-consumed: calc(var(--jx-radius-effective, 0px) * var(--jx-radius-factor-effective, 1))'
      : undefined,
  );
  const rootStyle = $derived(
    [carriers, radiusConsumed, styleAttribute].filter(Boolean).join('; ') || undefined,
  );
</script>

<figure
  bind:this={uniRoot}
  class={cx(
    'jx-table',
    tableStyles.frame,
    className,
  )}
  style={rootStyle}
  data-density={densityRungOf(d.density)}
  class:dark={d.theme === 'dark'}
>
  <table
    data-density={densityRungOf(d.density)}
    class={cx(tableStyles.table, dense && 'dense')}
    data-stack={stack ? undefined : 'off'}
  >
    {#if caption}
      <caption class={cx(tableStyles.caption)}>{caption}</caption>
    {/if}
    {@render children()}
  </table>
</figure>

law

The frame-width laws

Two responsive engines read the frame's own width; semantics stay the consumer's; paint is a token surface with a per-instance seam; density is the one opinion. Measured across the 30rem line in the workbench above.

PropertyTypeDefaultDescription
the frame-width laws container: jx-table / inline-sizetwo engines read the FRAME's own width, never the viewport)s — at ≥ 30rem the scroll law (fit-content columns, native overflow-x, data-sticky pins behind a hairline fold, all logical properties so RTL mirrors free); below it the stack law (thead folds away, td[data-label] renders a muted label beside the value, the first cell takes the head surface). stack={false} pins the scroll law at every widthannounces: measured both sides of the 30rem line in the workbench
semantics stay the consumer's thead · tbody · tfoot · th · td · captionreal elements authored in the children snippet — rows are never wrapped, numeric alignment stays a consumer class; the component owns only the frameannounces: the accessibility tree is the author)s, wholesale
the a11y split caption · scope · data-label · aria-sort · aria-livecaption names the table; scope pairs headers with cells; data-label feeds the card law)s labels; the recipes add aria-sort on the sorted th only (the caret stays aria-hidden) and aria-live=polite on the selection readoutannounces: announcements are measured, never assumed
the color-freedom seam style="--jx-table-hover: …"every paint routes through the --jx-table-* local tokens — one var retunes one aspect per instance; the hover follows --brand-hue at runtime (one number recolors it)announces: a token surface, not a palette
the density floor explicit ?? inherited ?? own smdensity is the family)s ONE opinion — the design-frozen local fallback (dense tabular rows are the declared posture), riding the eager-capture provider lane that keeps the chain boundary-legal; the rung stamps by default (measured)announces: the fleet)s density-opinion family
separate borders border-collapse: separaterequired by the sticky law — collapsed borders tear under sticky pins in some engines; rendering is identical here because the language carries only horizontal hairlinesannounces: an engine constraint turned into the table)s whole visual grammar

types

Responsive modes

Keep the native table markup; dense reduces row height and stack selects the narrow-frame card law.

table · modes

default
regular rows
dense scroll
compact, always scrollable
table-modes-demo.svelte svelte
<script lang="ts">
  import Table from '@ui/table/table.svelte';
</script>

<!-- keep the native markup; dense reduces row height and stack selects
     the narrow-frame card law -->
<div class="grid gap-4 md:grid-cols-2">
  <Table caption="default">
    <tbody><tr><td>regular rows</td></tr></tbody>
  </Table>
  <Table dense stack={false} caption="dense scroll">
    <tbody><tr><td>compact, always scrollable</td></tr></tbody>
  </Table>
</div>

axes

The eight axes on table

Density is the family's ONE opinion (own sm — the rung stamps by default); the seven sibling axes are no-own supply landing on the frame root, and the consumer-authored cells inherit through the cascade. Paint stays on the --jx-table-* token surface — the color-freedom seam retunes it per instance.

PropertyTypeDefaultDescription
density 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings)'sm' · the family's OWNOWN, 'sm' — the fleet's density-opinion family: dense tabular rows are the declared posture, so the rung stamps BY DEFAULT with no prop (measured data-density="sm" on the bare workbench table); an explicit lane overrides, an inherited provider wins over the own. The reactive bridged write carries the supply (the provider-snapshot kernel law — density never rides the literal). Number unit: coefficient.
size 'small' | 'medium' | 'large' | 'auto' | number'auto'STAMP-ONLY, VOICE ON THE FRAME — the §11 pair lands on the figure (measured 18px computed) while the table's OWN text voice is the density-tuned 12px (--jx-text at the sm own — dense tabular text is the declared posture and wins inside the frame); your cells read the table's voice, the frame reads the lane. Number unit: px.
shape 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto'CONSUMED — corner-shape rides the §14 alias ladder on the frame (the radius law's base is 0 with the bevel upgrade where supported — a table with square corners by intent). Number unit: none.
radius 'small' | 'medium' | 'large' | 'auto' | number'auto'STAMPED AND CONSUMED (fixed in-campaign): the lane stamps --jx-radius-effective on the frame and the atom reads var(--jx-radius-consumed, var(--jx-radius)) — measured post-fix: explicit 12 → corner 12px; 'large' → 10px; auto stamps nothing and the site fallback paints 8px. (The pre-fix defect — the atom bound the base token, computed 8px always — is receipted in the CODE report and the ledger's in-campaign fix note.) Number unit: px.
color 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto'SUPPLY-ONLY, TOKEN SURFACE — the lane lands on the root, but the table's paint is the --jx-table-* local surface (ground, head, hover, hairline, rule, edge); per-instance retuning goes through the style seam, not the hue lane. Number unit: hue degrees.
theme 'light' | 'dark' | 'system' | 'auto''auto'BRIDGE — class:dark lands on the frame (measured) and the --jx-table-* tokens re-voice through the theme scope; the consumer-authored cells ride the same bridge. No number lane.
elevation 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto'SUPPLY-ONLY — the frame carries a hairline ring, not a shadow (the table sits IN the plane, flush by intent); the lane supplies for what you nest inside the frame. Number unit: dp.
motion 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto'SUPPLY-ONLY — the carrier lands on the root; the family's only choreography is the hover tone (a token mix, not a transition law) and the pulse-free static frame. Reduced motion freezes nothing here because nothing moves. Number unit: coefficient.
TokenDefaultSource
--jx-table-surfacevar(--background)color
--jx-table-headvar(--muted)color
--jx-table-hoverprimary 7% mixcolor
--jx-table-hairline / -rule / -edgeborder mixescolor
the density channels--jx-inset / -gap / -stack / -text / -linedensity
container: jx-tableinline-sizestructural

Table · universal props

axes
consumerstatus
uniptylive
betlangbeta
named steps
consumerstatus
mermaidlive
stamps
consumer
sysdlglive
table-universal.svelte svelte
<Table caption="axes" size={18}>…</Table>

The stamps panel rides the merge law: density="small" overrides the own sm rung, the dark bridge re-voices the token surface, and radius={12} lands on the frame — the family's concentric anchor. The cells inherit the cascade untouched.

api

Table props

Properties

PropertyTypeDefaultDescription
caption string''Native table caption.
dense booleanfalseCompact row padding — measured note (2026-09-23): the gap channel equals the inset channel at every measured rung (8px at sm, 16px at lg — the A/B ran both), so the dense rule is a visual no-op on row height (34px either way); whether it ever bites is the W-next #10 design call.
stack booleantrueEnables narrow-frame card rows.
style string''Overrides local table tokens.

Universal props

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity across the motion kernels (§8)

a11y

Accessibility

ARIA

AttributeValueDescription
captionnative table captionNames the table for assistive technology.
scopecol | rowAssociates headers with their cells.
data-labelstringLabels values in the narrow card layout.
aria-sortascending | descendingRecipe wiring: lives on the sorted th only; the caret glyph stays aria-hidden.
aria-livepoliteSelection count readout announces changes without stealing focus.
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h