Tabs
Sectioned content switching with a full WAI-ARIA tablist: three visual treatments, keyboard-driven.
Live demo
Tabs presents one panel of content at a time, switched by a row of tab triggers. It implements the complete WAI-ARIA tabs pattern: a role="tablist" of role="tab" buttons paired with role="tabpanel" regions, roving tabindex (only the selected tab is in the tab order), arrow-key navigation with Home/End jumps, and aria-selected / aria-controls / aria-labelledby wiring done for you.
The activation knob chooses automatic activation (arrowing selects as you move) or manual (arrow to move focus, Enter/Space to select). Three visual treatments (underline, pill, and enclosed) change the chrome without touching the semantics. Authors declare each tab as a slot="tab" (or data-xtyle-tab) element and its content as the matching slot="panel" (or data-xtyle-panel) element; the element pairs them by order, assigns ids, and owns the selection state. Astro consumes a child's slot attribute to route it, so the data- markers are the ones that survive there.
When to use
How this component composes with the rest of the set.
Props
13 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
wrap (the default) flows the tabs onto as many rows as they need, so every tab stays visible and nothing is hidden behind a gesture. scroll keeps them on one row and scrolls, which suits a strip whose tab order carries meaning (a timeline, a wizard) and can afford a scrollbar. Prefer wrap when the tab count is data-driven and unbounded. | ||||
value (or its zero-based index if no value is set). Reflected on change; bindable in Svelte. Defaults to the first enabled tab. | ||||
aria-label. Required unless labelledby is set. | ||||
aria-labelledby. Takes precedence over label. | ||||
xtyle-tabs::part(tablist) { top: … }. | ||||
overflow on the panel from outside cannot fix that, because the panel is a grid item of an area that is itself unbounded. | ||||
role="tablist" while you render the content yourself against the change event / bound value. Lets a persistent sidebar or split live outside the panel region and keep its own state across switches. In Svelte, omit the panel snippet to get this automatically. Tabs omit aria-controls in this mode since the element owns no panels. | ||||
{ value, label, disabled? } declaring the tabs; panel content comes from the panel snippet keyed by value. The same list is items on every other binding. | ||||
[slot="tab"] / [slot="panel"] pairs: an array of { value, label, disabled? }, serialized to JSON on the attribute. The Svelte binding spells the same list tabs. | ||||
panel snippet only once its tab is first shown, then keep it mounted (keep-alive). Off by default (every panel renders up front). Reach for it when panels are heavy (an editor, a chart, a data grid) or must lay out only while visible; the tab strip, roving focus, and a11y are unchanged. The active panel mounts on the client after hydration, so an SSR page shows it a beat later. |
Events
What the component emits, and what rides along on event.detail. The name in the
first column is the one addEventListener takes.
| Event | Detail | Bindings | Description |
|---|---|---|---|
Appearance
Variants
underline
A minimal row with a moving underline under the selected tab. The default.
pill
Tabs sit in a tinted track; the selected tab becomes a solid accent pill.
enclosed
Folder-style tabs that connect to the panel; the selected tab joins the content surface.
Sizes
sm
Compact.
md
Default.
States
selected
The active tab. Tone-colored text, plus the variant's selection chrome (underline / pill / folder). When the theme's --selection-cue resolves to marker (a high-contrast or redundant-cues algorithm), the selected tab gains a non-color check glyph so selection never rests on color alone.
hover
Pointer over an unselected tab; overlay paints the hover tint and the text brightens.
active
Tab pressed. Overlay paints the press tint.
focus-visible
Keyboard focus on a tab or panel. A token-colored ring, plus a transparent outline that becomes real in forced-colors mode.
disabled
A non-selectable tab. Muted ink, overlay suppressed, skipped by arrow-key navigation.
Anatomy
The named parts that make up the component. A ::part() handle is reachable from your own stylesheet; the class beside it is the component's internal selector, which a shadow boundary keeps to itself.
tabs
The root wrapper carrying the variant and size classes; stacks the tablist over the active panel.
tablist
The horizontal row of tab triggers (role=tablist).
tab
A single tab trigger (role=tab). Selected, hover, active, focus, and disabled states all live here.
panels
The region the panels are stacked in — one grid cell they all share, so the strip never reflows as the taller panel takes over. It is the node that has to be bounded for a panel to scroll rather than overflow, which is what fill does.
panel
A content region (role=tabpanel) shown only when its tab is selected; focusable so keyboard users can scroll it.
overlay
The pseudo-element behind each tab that paints hover and active state tints.
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
Live coverage check against the xtyle-default register
(derive(xtyleDefault, { anchors }) →
coverComponent(manifest, register)). Every token this component
consumes must be a key the algorithm produces.
--accent--accent-fg--accent-text--bg-1--bg-2--border-normal--border-thick--border-thin--duration-fast--ease-standard--fg-0--fg-1--fg-2--fg-disabled--font-sans--leading-tight--line--radius-lg--radius-md--radius-none--radius-sm--ring--scrollbar-thumb--scrollbar-track--selection-cue--space-0--space-1--space-2--space-3--space-4--state-hover--state-press--text-body--text-sm--weight-mediumSlots
A tab trigger's label. Each slot="tab" or data-xtyle-tab element becomes a tab; its value attribute keys it and disabled marks it unselectable. Astro consumes slot to route children, so use data-xtyle-tab there. Carries markup, not just text: an icon or a count badge beside the label survives into the render, including the static one. (html / astro)
A tab's content. Each slot="panel" or data-xtyle-panel element is paired with the tab of the same order (use data-xtyle-panel under Astro). A full render slot: nested components keep working, and under Astro the pairing resolves at build time so the tab strip and panels are complete before any script runs. In Svelte this is a panel snippet receiving the active value.
Accessibility
Code
Variants and activation
The same tablist semantics across the three treatments; declare tabs and panels as paired slotted elements (or, in Svelte, a tabs array plus a panel snippet).
<xtyle-tabs variant="underline" label="Account settings" value="profile">
<button slot="tab" value="profile">Profile</button>
<div slot="panel">
<p>Your public profile information.</p>
</div>
<button slot="tab" value="billing">Billing</button>
<div slot="panel">
<p>Manage your subscription and payment methods.</p>
</div>
<button slot="tab" value="api" disabled>API</button>
<div slot="panel">
<p>API keys (coming soon).</p>
</div>
</xtyle-tabs><script lang="ts">
import { Tabs } from "@xtyle/svelte";
let active = $state("profile");
const tabs = [
{ value: "profile", label: "Profile" },
{ value: "billing", label: "Billing" },
{ value: "api", label: "API", disabled: true },
];
</script>
<Tabs {tabs} variant="pill" label="Account settings" bind:value={active}>
{#snippet panel(value)}
{#if value === "profile"}<p>Your public profile information.</p>{/if}
{#if value === "billing"}<p>Manage your subscription and payment methods.</p>{/if}
{#if value === "api"}<p>API keys (coming soon).</p>{/if}
{/snippet}
</Tabs>---
import { Tabs } from "@xtyle/astro";
---
<!-- Astro consumes a child's `slot` attribute to route it, so mark tabs and
panels with `data-xtyle-tab` / `data-xtyle-panel` here. -->
<Tabs variant="enclosed" label="Account settings" value="profile">
<span data-xtyle-tab value="profile">Profile</span>
<div data-xtyle-panel>
<p>Your public profile information.</p>
</div>
<span data-xtyle-tab value="billing">Billing</span>
<div data-xtyle-panel>
<p>Manage your subscription and payment methods.</p>
</div>
</Tabs>More tabs than room
A strip too wide for its container wraps onto more rows by default, so every tab stays visible and reachable without a gesture. Set overflow="scroll" to keep them on one row instead, which suits a strip whose left-to-right order is part of the meaning.
<!-- The default: eighteen tabs flow onto as many rows as they need. -->
<xtyle-tabs variant="pill" label="Project settings" value="overview">
<span slot="tab" data-value="overview">Overview</span>
<span slot="tab" data-value="members">Members</span>
<span slot="tab" data-value="webhooks">Webhooks</span>
<!-- …fifteen more -->
<div slot="panel">Overview panel</div>
<div slot="panel">Members panel</div>
<div slot="panel">Webhooks panel</div>
</xtyle-tabs>
<!-- One row, scrolled, for a strip whose order carries meaning. -->
<xtyle-tabs variant="pill" overflow="scroll" label="Release timeline" value="v1">
<span slot="tab" data-value="v1">v1.0</span>
<span slot="tab" data-value="v2">v1.1</span>
<div slot="panel">v1.0 notes</div>
<div slot="panel">v1.1 notes</div>
</xtyle-tabs><script lang="ts">
import { Tabs } from "@xtyle/svelte";
// A data-driven tab count is exactly the case `wrap` exists for: nothing
// off screen, no gesture required to reach a tab.
const tabs = $derived(project.sections.map((s) => ({ value: s.id, label: s.name })));
let section = $state("overview");
</script>
<Tabs {tabs} bind:value={section} label="Project settings" variant="pill">
{#snippet panel(value)}
<SectionBody id={value} />
{/snippet}
</Tabs>---
import Tabs from "@xtyle/astro/Tabs.astro";
const sections = await getSections();
---
<Tabs variant="pill" label="Project settings" value={sections[0].id}>
{sections.map((s) => <span data-xtyle-tab="" data-value={s.id}>{s.name}</span>)}
{sections.map((s) => <div data-xtyle-panel=""><SectionBody id={s.id} /></div>)}
</Tabs>Headless tablist
Omit the panels and the element is a bare tablist that drives a value: the tabs switch the main view while a sidebar persists outside the panel region, keeping its own state. In Svelte, drop the panel snippet; in html / astro, set tablist.
<xtyle-tabs id="view-tabs" tablist label="Workspace view" value="editor">
<button slot="tab" value="editor">Editor</button>
<button slot="tab" value="preview">Preview</button>
<button slot="tab" value="diff">Diff</button>
</xtyle-tabs>
<div class="workspace">
<main id="view"><!-- rendered by your own change listener --></main>
<aside><!-- persists across tab switches --></aside>
</div>
<script type="module">
const tabs = document.getElementById("view-tabs");
tabs.addEventListener("change", (e) => renderView(e.detail.value));
</script><script lang="ts">
import { Tabs } from "@xtyle/svelte";
let view = $state("editor");
const tabs = [
{ value: "editor", label: "Editor" },
{ value: "preview", label: "Preview" },
{ value: "diff", label: "Diff" },
];
</script>
<!-- No panel snippet: Tabs is a bare tablist driving `view`. -->
<Tabs {tabs} label="Workspace view" bind:value={view} />
<div class="workspace">
<main>
{#if view === "editor"}<CodeEditor />{/if}
{#if view === "preview"}<Preview />{/if}
{#if view === "diff"}<DiffView />{/if}
</main>
<!-- The inspector lives outside the tabs, so it keeps its scroll and selection across switches. -->
<aside><Inspector /></aside>
</div>