registry:ui · ARIA
tabs — one value, four files, any layout
The WAI-ARIA tabs pattern, composition-first: the root owns only the selected value and hands it to the family through context — tablist, triggers and panels lay out anywhere in the subtree. Selection paint lives in the indicator engine: a single shared element measured to the active trigger and translated between positions, in five materials — line, pill, outline, glass, liquid — or none at all, or replaced entirely by your own snippet paint. Triggers carry the Material grammar (icon lanes, icon-only, stacked columns) and the strip lays out inline, grow, scroll or wrap — every horizontal strip degrading to a hidden-scrollbar scroll run with on-demand DOM chevron buttons when content outgrows the container. The keyboard contract is untouched: automatic activation (focus moves select), roving tabindex, deterministic trigger/panel ids, and hidden — attribute, not CSS — background panels.
$npx jixoai-ui add tabs
https://ui.jixoai.com/r/tabs.json
overview
Overview
Composition-first APG: the root owns one value, the family shares it through context, and the keyboard contract — roving tabindex, automatic activation, hidden panels — rides real key events, measured.
The pattern is the WAI-ARIA tabs contract, composition-first: the root owns ONLY the
selected value and hands it to the family through context — the role=tablist strip, the role=tab triggers and the role=tabpanel panels lay out anywhere in the
subtree (sidebar + panel grid, inline docs switches). Ids are deterministic — the
trigger's id pairs the panel's aria-labelledby — so lazy panels pair without a
registration handshake, and background panels are hidden — attribute, not CSS: inert, no leaking interactions.
The keyboard surface is real key events, not simulated attributes: ←/→ (or ↑/↓ on the
vertical axis — RTL flips the reading) walk the enabled triggers with a ROVING
tabindex — exactly one tab stop, following focus; Home/End jump the ends; the walk
wraps and skips disabled triggers. Activation is AUTOMATIC by default (focus moves
select — terminal immediacy); activation="manual" moves focus only and
lets Enter/Space commit. The tablist itself sits at tabindex="−1": Tab leaves the
strip entirely — focus is never trapped.
Selection paint lives in the indicator engine — ONE shared element measured to the
active trigger and translated between positions (240ms cubic-bezier(0.2, 0.8, 0.2, 1);
reduced motion kills the travel and the bar jumps). The eight axes are near-zero by
measurement: density is the ONE consumed axis (the kernel lanes scale the triggers),
the inks ride the ROOT-PINNED alias chains (frozen under a scoped dark island,
re-derived at root-level dark), and shape/radius/color/elevation/motion supply unread.
Kinship: button-group (the strip edge system
tabs borrows), segmented (the outline feel).
tabs
Tab across the strip: arrows walk and select, Home/End jump the ends, and the disabled trigger is skipped. The echo footer surfaces the bound value and the last change — onchange fires either way.
The rendered surface — what reviewers see by default.
<Tabs value="preview">
<TabsList>
<TabsTrigger value="preview">preview</TabsTrigger>
<TabsTrigger value="raw">raw</TabsTrigger>
<TabsTrigger value="diff">diff</TabsTrigger>
<TabsTrigger value="audit" disabled>audit</TabsTrigger>
</TabsList>
<TabsContent value="preview">…</TabsContent>
</Tabs>indicator engine
The indicator is the point
Selection paint lives in exactly one place: a single shared indicator element, measured to the active trigger and translated between positions — 240ms cubic-bezier(0.2, 0.8, 0.2, 1) travel, no travel at all under prefers-reduced-motion, and a ResizeObserver re-fit that lands without animation when triggers resize or the strip reflows. The triggers stay restrained (text-foreground emphasis only) while the material carries the paint: line is the 2px var(--primary) bar riding the list edge — the default, now sliding — pill a tonal translucent fill hugging the trigger, outline a 1px box for the segmented feel, glass the frost member of the shared glass law sheet (a blur + saturate frosted pill), and liquid the LENS — the glass item's liquidGlass attachment (the component's OWN material mount, derived from indicator='liquid') carries the refraction chain over the pill (the retired feTurbulence noise SVG is gone). Engines without url() backdrop-filters keep liquid on its frost base — same geometry, honest paint.
tabs · indicator materials
<script lang="ts">
import Tabs from '@ui/tabs.svelte';
import TabsList from '@ui/tabs-list.svelte';
import TabsTrigger from '@ui/tabs-trigger.svelte';
</script>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div class="flex flex-col gap-3 border border-border/60 p-4">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">line</span>
<Tabs value="preview">
<TabsList>
<TabsTrigger value="preview">preview</TabsTrigger>
<TabsTrigger value="raw">raw</TabsTrigger>
<TabsTrigger value="diff">diff</TabsTrigger>
<TabsTrigger value="audit">audit</TabsTrigger>
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">the 2px var(--primary) bar riding the list edge — the default, now sliding.</span>
</div>
<div class="flex flex-col gap-3 border border-border/60 p-4">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">pill</span>
<Tabs value="build">
<TabsList indicator="pill">
<TabsTrigger value="build">build</TabsTrigger>
<TabsTrigger value="ship">ship</TabsTrigger>
<TabsTrigger value="test">test</TabsTrigger>
<TabsTrigger value="audit">audit</TabsTrigger>
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">a tonal translucent fill hugging the active trigger.</span>
</div>
<div class="flex flex-col gap-3 border border-border/60 p-4">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">outline</span>
<Tabs value="day">
<TabsList indicator="outline">
<TabsTrigger value="day">day</TabsTrigger>
<TabsTrigger value="week">week</TabsTrigger>
<TabsTrigger value="month">month</TabsTrigger>
<TabsTrigger value="all">all</TabsTrigger>
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">a 1px outline box — the segmented-control feel.</span>
</div>
<div class="flex flex-col gap-3 border border-border/60 p-4">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">glass</span>
<div class="rounded-md bg-[linear-gradient(115deg,oklch(0.8_0.15_var(--brand-hue)),oklch(0.8_0.14_260),oklch(0.84_0.13_145))] p-3">
<Tabs value="frost">
<TabsList indicator="glass">
<TabsTrigger value="frost">frost</TabsTrigger>
<TabsTrigger value="ice">ice</TabsTrigger>
<TabsTrigger value="mist">mist</TabsTrigger>
<TabsTrigger value="vapor">vapor</TabsTrigger>
</TabsList>
</Tabs>
</div>
<span class="text-muted-foreground text-[12px]">a frosted pill — backdrop blur + saturate, staged here over three brand hues.</span>
</div>
<div class="flex flex-col gap-3 border border-border/60 p-4">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">liquid</span>
<div class="rounded-md bg-[linear-gradient(115deg,oklch(0.8_0.14_260),oklch(0.8_0.15_var(--brand-hue)),oklch(0.84_0.13_145))] p-3">
<Tabs value="flow">
<TabsList indicator="liquid">
<TabsTrigger value="flow">flow</TabsTrigger>
<TabsTrigger value="drip">drip</TabsTrigger>
<TabsTrigger value="wave">wave</TabsTrigger>
<TabsTrigger value="pool">pool</TabsTrigger>
</TabsList>
</Tabs>
</div>
<span class="text-muted-foreground text-[12px]">the liquid lens — the glass item's liquidGlass attachment mounts kube's refraction chain over the pill (the component's OWN material mount, r4 — no consumer hook); the retired noise SVG is gone, frost stands where url() backdrop-filters are unsupported.</span>
</div>
<div class="flex flex-col gap-3 border border-border/60 p-4">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">none</span>
<Tabs value="plain">
<TabsList indicator="none">
<TabsTrigger value="plain">plain</TabsTrigger>
<TabsTrigger value="bare">bare</TabsTrigger>
<TabsTrigger value="calm">calm</TabsTrigger>
<TabsTrigger value="still">still</TabsTrigger>
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">no paint at all — the selected trigger's own emphasis carries the selection.</span>
</div>
</div>anatomy
Trigger anatomy — the Material grammar
A trigger is a text label by default. icon adds the leading lane, iconEnd the trailing lane, stack flips to the Material icon-over-label column, and the icon-only form passes just the snippet plus aria-label. Icons ride the slot-vs-padding law: the lane replaces its side's padding, so an icon rides the edge at half the inset instead of widening the hit box — and the icon-only trigger demands aria-label, because an accessible name is never optional. The selected trigger keeps its text-foreground emphasis under every anatomy.
tabs · anatomy
<script lang="ts">
import Tabs from '@ui/tabs.svelte';
import TabsList from '@ui/tabs-list.svelte';
import TabsTrigger from '@ui/tabs-trigger.svelte';
</script>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div class="flex flex-col gap-3 border border-border/60 p-4">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">text — default</span>
<Tabs value="preview">
<TabsList>
<TabsTrigger value="preview">preview</TabsTrigger>
<TabsTrigger value="raw">raw</TabsTrigger>
<TabsTrigger value="diff">diff</TabsTrigger>
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">the bare micro-label — all a tab usually needs.</span>
</div>
<div class="flex flex-col gap-3 border border-border/60 p-4">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">leading icon</span>
<Tabs value="code">
<TabsList>
<TabsTrigger value="code">
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>{/snippet}
code
</TabsTrigger>
<TabsTrigger value="terminal">
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>{/snippet}
terminal
</TabsTrigger>
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">the icon lane replaces the start padding — the glyph rides the edge.</span>
</div>
<div class="flex flex-col gap-3 border border-border/60 p-4">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">trailing icon</span>
<Tabs value="open">
<TabsList>
<TabsTrigger value="open">
open
{#snippet iconEnd()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>{/snippet}
</TabsTrigger>
<TabsTrigger value="source">
source
{#snippet iconEnd()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M7 17 17 7"/><path d="M7 7h10v10"/></svg>{/snippet}
</TabsTrigger>
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">iconEnd mirrors the lane on the far side — disclosure glyphs live here.</span>
</div>
<div class="flex flex-col gap-3 border border-border/60 p-4">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">icon-only</span>
<Tabs value="term">
<TabsList>
<TabsTrigger value="term" aria-label="terminal">
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>{/snippet}
</TabsTrigger>
<TabsTrigger value="watch" aria-label="watch">
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>{/snippet}
</TabsTrigger>
<TabsTrigger value="src" aria-label="source">
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>{/snippet}
</TabsTrigger>
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">the snippet is the glyph; aria-label is the name — never skip it.</span>
</div>
<div class="flex flex-col gap-3 border border-border/60 p-4">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">stack</span>
<Tabs value="overview">
<TabsList>
<TabsTrigger value="overview" stack>
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg>{/snippet}
overview
</TabsTrigger>
<TabsTrigger value="activity" stack>
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>{/snippet}
activity
</TabsTrigger>
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">the Material stacked tab — icon over label in a centered column.</span>
</div>
</div>layouts
Layouts — inline, grow, scroll, wrap
The strip's geometry is a prop, and it composes with every material. inline (the default) sizes the strip to its content. grow stretches every trigger to an equal share of the full width — the Material full-bleed top bar. scroll declares a horizontal overflow run; wrap flows multiple rows instead of scrolling. Overflow is a contract, not a memory: every horizontal strip degrades to a hidden-scrollbar scroll run the moment its content outgrows the container, and DOM chevron buttons overlay the inline edges on demand — only toward the direction that can still travel, fading as the run approaches its boundary (the glyphs are css vars: override them per context). Travel is smooth and settles on triggers by proximity snap, and the edge treatment is the scrollEffect builder — ramp() by default (the ONE member-ramp: opacity/blur/translate toggles, all on; ramp({ blur: false }) the cheapest posture), progressBlur()/shadow() for the veiled edges (the blur ladder · the contrast ghost). Grow, scroll and wrap accept any indicator — the demos stage grow with pill and scroll with the default line on purpose; wrap rides the default line too, where each row underlines its own active tab.
tabs · layouts
<script lang="ts">
import Tabs from '@ui/tabs.svelte';
import TabsList, { progressBlur, ramp, shadow } from '@ui/tabs-list.svelte';
import TabsTrigger from '@ui/tabs-trigger.svelte';
</script>
<div class="flex w-full max-w-2xl flex-col gap-7">
<div class="flex flex-col gap-2.5">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">inline — default</span>
<Tabs value="preview">
<TabsList>
<TabsTrigger value="preview">preview</TabsTrigger>
<TabsTrigger value="raw">raw</TabsTrigger>
<TabsTrigger value="diff">diff</TabsTrigger>
<TabsTrigger value="audit">audit</TabsTrigger>
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">intrinsic widths — the strip is exactly its content.</span>
</div>
<div class="flex flex-col gap-2.5">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">grow</span>
<Tabs value="editor">
<TabsList layout="grow" indicator="pill" class="w-full max-w-md">
<TabsTrigger value="editor">editor</TabsTrigger>
<TabsTrigger value="review">review</TabsTrigger>
<TabsTrigger value="ship">ship</TabsTrigger>
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">equal-width stretched triggers — the Material full-bleed bar, staged with the pill material.</span>
</div>
<div class="flex flex-col gap-2.5">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">scroll</span>
<Tabs value="gamma">
<TabsList layout="scroll" class="max-w-md">
{#each ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta', 'iota', 'kappa', 'lambda', 'mu'] as letter (letter)}
<TabsTrigger value={letter}>{letter}</TabsTrigger>
{/each}
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">twelve triggers in the overflow run — the scrollbar is hidden, the chevron fades scroll-driven toward the boundary it reaches, the walk is not.</span>
</div>
<div class="flex flex-col gap-2.5">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">wrap</span>
<Tabs value="gamma">
<TabsList layout="wrap" class="max-w-sm">
{#each ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta', 'iota', 'kappa', 'lambda', 'mu'] as letter (letter)}
<TabsTrigger value={letter}>{letter}</TabsTrigger>
{/each}
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">twelve triggers flowing rows — no scroll run, and the line material underlines each row's own active tab.</span>
</div>
<div class="flex flex-col gap-2.5">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">scroll · scrollEffect=ramp()</span>
<Tabs value="gamma">
<TabsList layout="scroll" scrollEffect={ramp()} class="max-w-md">
{#each ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta', 'iota', 'kappa', 'lambda', 'mu'] as letter (letter)}
<TabsTrigger value={letter}>{letter}</TabsTrigger>
{/each}
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">triggers blur, fade and slide as they clip under the run's edges — each ramp follows its own clipped fraction (scroll-following stamps, every engine); the toggles and magnitudes (radius, distance) are builder options.</span>
</div>
<div class="flex flex-col gap-2.5">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">scroll · scrollEffect=progressBlur()</span>
<Tabs value="gamma">
<TabsList layout="scroll" scrollEffect={progressBlur()} class="max-w-md">
{#each ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta', 'iota', 'kappa', 'lambda', 'mu'] as letter (letter)}
<TabsTrigger value={letter}>{letter}</TabsTrigger>
{/each}
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">the progressive-blur ladder veils both edges — twin grid bands over the run, entering by scroll-driven translate (width is a builder option).</span>
</div>
<div class="flex flex-col gap-2.5">
<span class="font-nav text-primary text-[11px] uppercase tracking-[0.24em]">scroll · scrollEffect=shadow()</span>
<Tabs value="gamma">
<TabsList layout="scroll" scrollEffect={shadow({ width: "5rem" })} class="max-w-md">
{#each ['alpha', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'eta', 'theta', 'iota', 'kappa', 'lambda', 'mu'] as letter (letter)}
<TabsTrigger value={letter}>{letter}</TabsTrigger>
{/each}
</TabsList>
</Tabs>
<span class="text-muted-foreground text-[12px]">the contrast-ghost veil (the separator ink law): backdrop-filter contrast() subtracts color toward mid tone — light grounds dim, dark grounds lift, no black overlay anywhere.</span>
</div>
</div>demo
Vertical — the sidebar shape
orientation=vertical swaps the arrow axis to ↑/↓ and moves the indicator to the right edge — line keeps the Material edge bar, pill hugs the active trigger instead. The panels are ordinary subtree — any layout receives them.
The project front page: what it is, where it lives.
Workspace name, default branch, timezone.
<script lang="ts">
import Tabs from '@ui/tabs.svelte';
import TabsList from '@ui/tabs-list.svelte';
import TabsTrigger from '@ui/tabs-trigger.svelte';
import TabsContent from '@ui/tabs-content.svelte';
</script>
<Tabs bind:value>
<TabsList>
<TabsTrigger value="preview">preview</TabsTrigger>
<TabsTrigger value="raw">raw</TabsTrigger>
</TabsList>
<TabsContent value="preview">…</TabsContent>
<TabsContent value="raw">…</TabsContent>
</Tabs>
<!-- the indicator: one shared element, measured to the active trigger
and animated between positions — five materials, or none -->
<TabsList indicator="pill">…</TabsList>
<TabsList indicator="glass" layout="grow">…</TabsList>
<!-- trigger anatomy: an icon lane replaces its side's padding;
icon-only needs aria-label; stack is the Material column -->
<TabsTrigger value="code">
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>{/snippet}
code
</TabsTrigger>
<TabsTrigger value="cli" aria-label="cli">
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>{/snippet}
</TabsTrigger>
<TabsTrigger value="overview" stack>
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m3 11 9-8 9 8"/><path d="M5 9v12h14V9"/></svg>{/snippet}
overview
</TabsTrigger>
<!-- vertical: arrows switch axis, the bar moves to the right edge -->
<Tabs bind:value>
<TabsList orientation="vertical" indicator="pill">…</TabsList>
…
</Tabs>snippet override
Own the paint, not the geometry
indicator also accepts a snippet block: pass an indicator(geo) children block and you replace the PAINT while the engine keeps the absolutely-positioned wrapper, the measurement, the 240ms travel and the ResizeObserver re-fit. The snippet receives the live geometry — x, y, w, h and orientation, in list-local pixels — so the paint can react to where the indicator sits and how wide the active trigger is. Here a gradient wash whose opacity densifies as the active trigger narrows — capped low enough that the label's ink keeps its contrast on top; the wrapper it fills is the engine's decision, never yours.
<TabsList>
{#snippet indicator(geo)}
<!-- your paint, inside the engine-owned wrapper (x/y/w/h are px) -->
<div
class="h-full w-full rounded-full bg-[linear-gradient(90deg,var(--primary),var(--accent))]"
style="opacity: {Math.max(0.15, 0.55 - geo.w / 300)}"
></div>
{/snippet}
<TabsTrigger value="nodes">nodes</TabsTrigger>
<TabsTrigger value="edges">edges</TabsTrigger>
<TabsTrigger value="paths">paths</TabsTrigger>
</TabsList>types
Activation and orientation
Tabs use automatic activation by default; vertical lists change the navigation axis.
tabs · activation
<script lang="ts">
import Tabs from '@ui/tabs.svelte';
import TabsList from '@ui/tabs-list.svelte';
import TabsTrigger from '@ui/tabs-trigger.svelte';
import TabsContent from '@ui/tabs-content.svelte';
</script>
<!-- automatic activation: moving focus selects the panel -->
<Tabs value="one">
<TabsList><TabsTrigger value="one">automatic</TabsTrigger></TabsList>
<TabsContent value="one">Focus selects this panel.</TabsContent>
</Tabs>usage
Usage
<script lang="ts">
import Tabs from '@ui/tabs.svelte';
import TabsList from '@ui/tabs-list.svelte';
import TabsTrigger from '@ui/tabs-trigger.svelte';
import TabsContent from '@ui/tabs-content.svelte';
</script>
<Tabs bind:value>
<TabsList>
<TabsTrigger value="preview">preview</TabsTrigger>
<TabsTrigger value="raw">raw</TabsTrigger>
</TabsList>
<TabsContent value="preview">…</TabsContent>
<TabsContent value="raw">…</TabsContent>
</Tabs>
<!-- the indicator: one shared element, measured to the active trigger
and animated between positions — five materials, or none -->
<TabsList indicator="pill">…</TabsList>
<TabsList indicator="glass" layout="grow">…</TabsList>
<!-- trigger anatomy: an icon lane replaces its side's padding;
icon-only needs aria-label; stack is the Material column -->
<TabsTrigger value="code">
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></svg>{/snippet}
code
</TabsTrigger>
<TabsTrigger value="cli" aria-label="cli">
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></svg>{/snippet}
</TabsTrigger>
<TabsTrigger value="overview" stack>
{#snippet icon()}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m3 11 9-8 9 8"/><path d="M5 9v12h14V9"/></svg>{/snippet}
overview
</TabsTrigger>
<!-- vertical: arrows switch axis, the bar moves to the right edge -->
<Tabs bind:value>
<TabsList orientation="vertical" indicator="pill">…</TabsList>
…
</Tabs>theming
Density and tokens
| Token | Default | Source |
|---|---|---|
| --jx-gap | density scale | density |
| --jx-inset | density scale | density |
| --jx-hit | density scale | density |
| --jx-text | density scale | density |
| --jx-line | density scale | density |
| --jx-glass-radius / --jx-glass-saturate | '2px' / 1.6 (liquid indicator) | component |
| --jx-tabs-veil | calc(var(--jx-inset) * 6) | component |
| --jx-tabs-progress | JS-stamped 0–1 | component |
| --jx-tabs-chevron-size | var(--jx-text-secondary) | component |
| --jx-tabs-chevron-inline-start | lucide chevron-left svg | component |
| --jx-tabs-chevron-inline-end | lucide chevron-right svg | component |
api
API
Three halves of one family: the root owns the value, the list owns the keyboard walk and the indicator engine, the trigger owns the anatomy. The keyboard surface is unchanged by every row below.
Tabs props
| Property | Type | Default | Description |
|---|---|---|---|
value bind | string | '' | Selected tab value. |
| activation | 'automatic' | 'manual' | 'automatic' | Selection behavior while moving focus. |
| onchange | (value: string) => void | — | Receives selection changes. |
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) |
TabsList props
| Property | Type | Default | Description |
|---|---|---|---|
| orientation | 'horizontal' | 'vertical' | 'horizontal' | Axis of travel: horizontal ←/→, vertical ↑/↓ with the indicator on the right edge. |
| indicator | 'line' | 'pill' | 'outline' | 'glass' | 'liquid' | 'none' | Snippet | 'line' | The selection paint — one shared element measured to the active trigger and animated between positions. A snippet block (indicator(geo), receiving x/y/w/h/orientation in list-local px) replaces the paint while the engine keeps the measured wrapper. |
| layout | 'inline' | 'grow' | 'scroll' | 'wrap' | 'inline' | inline is content-sized; grow stretches triggers to equal widths (the Material full-bleed bar); scroll declares a horizontal overflow run; wrap flows rows instead of scrolling. Every horizontal strip degrades to a hidden-scrollbar scroll run when content outgrows the container, with on-demand DOM chevron buttons overlaying the open direction. Composes with any material. |
| scrollEffect | ramp() | progressBlur() | shadow() | ramp() | Edge treatment while the run scrolls, built by the typed builders (the press-button effect convention). ramp({ opacity, blur, translate, distance, radius }) is the ONE member-ramp builder — every toggle defaults ON; each trigger ramps by its clipped fraction as it crosses an edge (scroll-following, consumed squared, every engine), and a toggle turned off never pays its property (ramp({ blur: false }) is the cheapest posture, the old slide). progressBlur({ blurLevels, width }) veils both edges with the progressive-blur ladder; shadow({ width }) is the single-layer contrast ghost — backdrop-filter contrast() subtracts color toward mid tone (light dims, dark lifts), never adds black. Both veils enter by scroll-driven translate; width overrides the band width. |
| class | string | '' | Appended to the composed classes. |
| ...rest | HTMLAttributes<HTMLDivElement> | spread | Every other attribute lands on the tablist element. |
TabsTrigger props
| Property | Type | Default | Description |
|---|---|---|---|
| value * | string | — | The tab identity — pairs with the same value on a TabsContent. |
| disabled | boolean | false | Skipped by the arrow walk and the roving tab stop. |
| icon | Snippet | — | Leading icon lane — replaces the start padding (the slot-vs-padding law); with no label text this is the icon-only form. |
| iconEnd | Snippet | — | Trailing icon lane — replaces the end padding. |
| stack | boolean | false | The Material stacked tab: icon over label in a centered column. |
| class | string | '' | Appended to the composed classes. |
| ...rest | HTMLButtonAttributes | spread | Every other button attribute rides through — aria-label lands here for icon-only triggers. |
axes
The eight axes on tabs
Density is the ONE consumed axis — managed on the root and read through the kernel scope lanes (--jx-hit/--jx-inset/--jx-gap/--jx-text rescale the triggers), with the root PROVIDING density to the family (inherit-then-provide). The other seven supply unread: the inks ride the ROOT-PINNED alias chains (frozen under a scoped dark island, re-derived at root-level dark — measured), the indicator's 240ms travel is the family's own transition (killed by reduced motion — the bar jumps), and shape/radius/color/elevation carry the supply chain to nested parts.
| Property | Type | Default | Description |
|---|---|---|---|
| density | '2xs' | 'xs' | 'sm' | 'default' | 'lg' | 'auto' | number (+ the five legacy spellings) | 'auto' | MANAGED AND CONSUMED — the one axis the family reads, through the DENSITY KERNEL: the root stamps data-density (measured lg/sm on the query seat) AND the triggers ride the scope lanes (--jx-hit/--jx-inset/--jx-gap/--jx-text), so the hit height, insets and label size scale with the rung (measured through the DensityDemo). The root also PROVIDES density to the family (inherit-then-provide: the panel's own prop beats it); the legacy lane narrows at the bridged edge (number/query carry no legacy rung — the rung stays ambient). Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | ROOT ECHO, PARTIALLY PINNED — the §1 carrier lands on the root, but the root is display:contents (box-tree transparent) and the triggers PIN their label to var(--jx-text), so the density kernel wins the cascade on the visible label while non-pinned descendants inherit the root font-size (both measured). Zero size-effective readers (grep receipt). Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — zero shape-channel readers (grep receipt over ui/tabs/). Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY — the concentric broadcast for nested parts (a card inside a panel consumes it); the strip's own geometry rides the indicator material, not the axis (grep receipt). Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SUPPLY-ONLY — the inks are PINNED (selected = --jx-foreground, idle = --jx-muted-foreground, the line bar = var(--primary)); zero --jx-color-effective readers (grep receipt). Repainting means re-theming, not the hue lane. Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | THE ROOT-PINNED ALIAS (the separator/spin shape #2, measured) — every ink (selected/idle label, the line bar's var(--primary), the host hairline --jx-border) rides :root alias chains, so a scoped .dark island re-derives inherited --foreground but the inks HOLD the light values (measured frozen), while root-level html.dark re-derives them (measured). class:dark stamps for composed descendants. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | SUPPLY-ONLY — zero elevation-carrier readers (grep receipt); the strip is flat by design (the glass material paints its own frost, not a shadow tier). Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | THE FAMILY'S OWN TRANSITION, AXIS UNREAD — the indicator travels 240ms cubic-bezier(0.2, 0.8, 0.2, 1) (transform/width/height, computed receipt) and prefers-reduced-motion kills the travel (transition: none — the bar JUMPS, measured): the motion axis stays supply-only (zero readers, grep receipt) while the family ships its own single transition. Number unit: coefficient. |
Receipts: the APG surface on real key events (ArrowRight from the selected trigger moves focus AND flips aria-selected — exactly one true per tablist; the roving tabindex follows focus; the walk wraps and skips the disabled trigger; Home/End jump the ends; inactive panels render inert through the hidden attribute with their content unmounted), the indicator's transition-frame (rAF-sampled transform passes through DISTINCT mid-travel positions over the 240ms cubic-bezier — and JUMPS under reduced motion, the transition killed), the density seat (data-density lg → sm across the 48rem viewport key AND the kernel rescale visible in the trigger's hit height — the one consumed axis), the theme split (three states measured on the seat's token chain: light --jx-foreground = --foreground = oklch(0 0 0); under a scoped .dark island --foreground flipped to oklch(1 0 0) while --jx-foreground HELD — the :root alias substitutes before inheritance; root-level dark re-derived the alias — plus the canvas stages' own data-theme="light" scope, which keeps in-page demos on their authored ground under site dark BY SCOPE, the third theming mechanism) and the grep receipts (zero --jx-*-effective readers over ui/tabs/) were measured on this page's served DOM (probe, task 42). LAW #19 id landscape: duplicate ids NONE page-wide; the keyed layout runs mount their letter triggers 12/12. The query() seat below rides the md viewport key (48rem) on the density lane.
<script lang="ts">
import Tabs from '@ui/tabs.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- the md key is the registered VIEWPORT scale (48rem): below it the
small rung stamps, at 48rem+ large wins — data-density flips AND
the kernel lanes rescale the triggers (the one axis tabs reads) -->
<Tabs density={query({ md: 'large' }, 'small')}>…</Tabs>The md key is the registered VIEWPORT scale (48rem): below it the small rung stamps (data-density="sm"); at 48rem and wider large wins ("lg" at a 1280 viewport, measured) — and because density is the consumed axis, the triggers rescale with it (the kernel lanes). Resize across 48rem.
Tabs · universal props
<Tabs size={18} density="small">…</Tabs>a11y
Accessibility
Keyboard
| Key | Action |
|---|---|
| Arrow keys | Move between enabled tabs. |
| Home / End | Move to the first or last tab. |
| Enter / Space | Select a focused tab in manual mode. |
ARIA
| Attribute | Value | Description |
|---|---|---|
| role | tablist, tab, tabpanel | Exposes the APG tabs pattern. |
| aria-selected | boolean | Marks the selected trigger. |
| aria-controls | panel id | Pairs each trigger with its panel. |
| aria-label | icon-only triggers | The accessible name when the snippet is the only content — the indicator materials stay decorative. |