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" element and its content as the matching slot="panel" element; the element pairs them by order, assigns ids, and owns the selection state.
When to use
How this component composes with the rest of the set.
Props
10 props, straight from the manifest.
| Prop | Type | Default | 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, with their selectors.
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.
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
--selection-cue
--space-0
--space-1
--space-2
--space-3
--space-4
--state-hover
--state-press
--text-body
--text-sm
--weight-medium
Slots
A tab trigger's label. Each slot="tab" element becomes a tab; its value attribute keys it and disabled marks it unselectable. (html / astro)
A tab's content. Each slot="panel" element is paired with the slot="tab" of the same order. 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";
---
<Tabs variant="enclosed" 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>
</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>