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.
$npx jixoai-ui add table
https://ui.jixoai.com/r/table.json
zero-dependency; recipes add their own families
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.
| Consumer | Status | Items | Coverage | Since | Runtime | Region | Docs |
|---|---|---|---|---|---|---|---|
unipty unipty.jixoai.com | live | 12 | 92% | 2025-11-04 | bun 1.2.19 | ap-southeast-1 | view |
openspecui openspecui.com | live | 9 | 78% | 2026-01-17 | node 24.20.0 | eu-central-1 | view |
ui.jixoai.com this site | live | 24 | 100% | 2026-08-30 | bun 1.2.19 | us-east-2 | view |
jixoai/www internal | beta | 6 | 45% | 2026-08-12 | node 22.14.0 | cn-north-1 | view |
agent-console internal | wip | 3 | 12% | 2026-07-03 | deno 2.3.5 | ap-northeast-1 | view |
| Total | — | 54 | — | — | — | — | — |
<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 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.
| id | ||||
|---|---|---|---|---|
| T-101 | harden registry install integrity | in review | high | 09-02 |
| T-102 | mirror manifest re-record | active | medium | 09-04 |
| T-103 | container-query card law | done | low | 08-27 |
| T-104 | anchor lease restore | backlog | urgent | 09-09 |
| T-105 | facet filter row | in review | medium | 09-01 |
| T-106 | select-all indeterminate | active | high | 09-03 |
<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).
| id | title | status | due |
|---|---|---|---|
| filters | |||
| T-101 | harden registry install integrity | in review | 09-02 |
| T-102 | mirror manifest re-record | active | 09-04 |
| T-103 | container-query card law | done | 08-27 |
| T-104 | anchor lease restore | backlog | 09-09 |
| T-105 | facet filter row | in review | 09-01 |
| T-106 | select-all indeterminate | active | 09-03 |
<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.
| id | title | status |
|---|---|---|
| T-101 | harden registry install integrity | in review |
| T-102 | mirror manifest re-record | active |
| T-103 | container-query card law | done |
| T-104 | anchor lease restore | backlog |
| T-105 | facet filter row | in review |
- 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.
| id | title | |
|---|---|---|
| T-101 | harden registry install integrity | |
| T-102 | mirror manifest re-record | |
| T-103 | container-query card law | |
| T-104 | anchor lease restore | |
| T-105 | facet filter row | |
| T-106 | select-all indeterminate |
0 of 6 selected
<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.
| id | title | priority | assignee | actions |
|---|---|---|---|---|
| T-101 | harden registry install integrity | high | @gaubee | |
| T-102 | mirror manifest re-record | medium | @kzf | |
| T-103 | container-query card law | low | @gaubee | |
| T-104 | anchor lease restore | urgent | @oss | |
| T-105 | facet filter row | medium | @kzf |
<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.
| when | event | level |
|---|---|---|
| 08-26 · 10:42 | harden registry install integrity | info |
| 08-27 · 11:42 | mirror manifest re-record | warn |
| 08-28 · 12:42 | container-query card law | error |
| 08-29 · 13:42 | anchor lease restore | info |
| 08-26 · 14:42 | facet filter row | warn |
| 08-27 · 15:42 | select-all indeterminate | error |
| 08-28 · 16:42 | countdown ticks | info |
| 08-29 · 17:42 | pagination window math | warn |
| 08-26 · 18:42 | column visibility popover | error |
| 08-27 · 19:42 | sticky scrollport law | info |
| 08-28 · 20:42 | row actions dropdown | warn |
| 08-29 · 21:42 | toolbar composition | error |
<!-- 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.
| id | assignee | actions | ||||
|---|---|---|---|---|---|---|
| T-101 | harden registry install integrity | in review | high | @gaubee | ||
| T-102 | mirror manifest re-record | active | medium | @kzf | ||
| T-103 | container-query card law | done | low | @gaubee | ||
| T-104 | anchor lease restore | backlog | urgent | @oss | ||
| T-105 | facet filter row | in review | medium | @kzf |
- 5 rows
- 8 rows
- 12 rows
<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.
| Component | Svelte 5 | Prerendered SSR | Files |
|---|---|---|---|
| press-button | yes | yes | 1 |
| code-card + highlight | yes | yes | 2 |
| table | yes | yes | 1 |
| tree-view | yes | yes | 1 |
| Total | — | — | 5 |
<!--
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.
| Property | Type | Default | Description |
|---|---|---|---|
| the frame-width laws | container: jx-table / inline-size | two 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 width | announces: measured both sides of the 30rem line in the workbench |
| semantics stay the consumer's | thead · tbody · tfoot · th · td · caption | real elements authored in the children snippet — rows are never wrapped, numeric alignment stays a consumer class; the component owns only the frame | announces: the accessibility tree is the author)s, wholesale |
| the a11y split | caption · scope · data-label · aria-sort · aria-live | caption 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 readout | announces: 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 sm | density 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: separate | required by the sticky law — collapsed borders tear under sticky pins in some engines; rendering is identical here because the language carries only horizontal hairlines | announces: 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
| regular rows |
| compact, always scrollable |
<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.
| Property | Type | Default | Description |
|---|---|---|---|
| density | 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings) | 'sm' · the family's OWN | OWN, '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. |
| Token | Default | Source |
|---|---|---|
| --jx-table-surface | var(--background) | color |
| --jx-table-head | var(--muted) | color |
| --jx-table-hover | primary 7% mix | color |
| --jx-table-hairline / -rule / -edge | border mixes | color |
| the density channels | --jx-inset / -gap / -stack / -text / -line | density |
| container: jx-table | inline-size | structural |
Table · universal props
| consumer | status |
|---|---|
| unipty | live |
| betlang | beta |
| consumer | status |
|---|---|
| mermaid | live |
| consumer | |
|---|---|
| sysdlg | live |
<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
| Property | Type | Default | Description |
|---|---|---|---|
| caption | string | '' | Native table caption. |
| dense | boolean | false | Compact 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 | boolean | true | Enables narrow-frame card rows. |
| style | string | '' | Overrides local table tokens. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
a11y
Accessibility
ARIA
| Attribute | Value | Description |
|---|---|---|
| caption | native table caption | Names the table for assistive technology. |
| scope | col | row | Associates headers with their cells. |
| data-label | string | Labels values in the narrow card layout. |
| aria-sort | ascending | descending | Recipe wiring: lives on the sorted th only; the caret glyph stays aria-hidden. |
| aria-live | polite | Selection count readout announces changes without stealing focus. |
see also
See also
The families the recipes compose with.