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
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" 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. (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). 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>
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>