Skip to main content

Tabs

Info:Navigation
33

Sectioned content switching with a full WAI-ARIA tablist: three visual treatments, keyboard-driven.

tab bar tablist segmented content views panels

Live demo

live · @xtyle/astro

Tabs

Live & keyboard-driven

Click a tab, or focus the tablist and use arrow keys, Home, and End. The panel swaps in place.

Derivation

An algorithm maps a handful of anchors and knobs into a full, internally-consistent register of OKLCH-derived tokens.

Coverage

The only hard contract: every token a component consumes must be produced by the active register. The check is live.

Emit

Once derived, a theme is just CSS custom properties plus the cascade — emit it as CSS or JSON and you're done.

Three variants

Underline, pill, and enclosed — the same tablist, three different framings.

variant="underline"

The default — a quiet underline marks the selected tab.

Pricing panel content.

Reviews panel content.

variant="pill"

A filled pill carries the selection.

Pricing panel content.

Reviews panel content.

variant="enclosed"

Folder-style tabs that connect to the panel.

Pricing panel content.

Reviews panel content.

Headless tablist

With no panels, Tabs is a bare tablist driving a value. The main view swaps on `change`, while the sidebar lives outside the tabs and keeps its own state across switches.

The editor view: code lives here.

Sizes & disabled state

Info:size="sm"

The compact size, with a disabled tab that's skipped by keyboard navigation.

Edit your display name and avatar.

Manage passwords and two-factor auth.

Billing is disabled on this plan.

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.

Pair each panel's content with any other component: a Field-laden form, a Card grid, a table.
Use activation="manual" when switching tabs is expensive (each panel fetches data), so arrowing previews focus without triggering loads.
Drive value from app state and listen for the change event (CustomEvent with detail.value) to keep tabs and routing in sync.
Set tablist (or omit the Svelte panel snippet) when the tabs control only part of a layout: an editor whose left pane switches per tab while a right sidebar persists across all of them, keeping its own scroll and selection.

Props

10 props, straight from the manifest.

PropTypeDefaultBindingsDescription
variant TabsVariant
underline pill enclosed
underline
html svelte astro
Visual treatment of the tablist. Does not affect behavior or semantics.
size Size
sm md
md
html svelte astro
Tab trigger size. Only `sm` and `md` are supported.
activation TabsActivation
automatic manual
automatic
html svelte astro
Automatic activation selects a tab as soon as it receives focus via arrow keys; manual moves focus first and requires Enter or Space to select.
value string
html svelte astro
The selected tab's `value` (or its zero-based index if no value is set). Reflected on change; bindable in Svelte. Defaults to the first enabled tab.
label string
html svelte astro
Accessible name for the tablist, applied as `aria-label`. Required unless `labelledby` is set.
labelledby string
html svelte astro
Id of an external element naming the tablist, applied as `aria-labelledby`. Takes precedence over `label`.
sticky boolean false
html svelte astro
Pins the tablist in place while the active panel scrolls under it, for tall, app-like panels where the tabs should stay reachable. Off by default so inline tabs scroll away with their content. Offset it past a fixed header with `xtyle-tabs::part(tablist) { top: … }`.
tablist boolean false
html svelte astro
Render only the tab strip, no panel region: the element drives selection, roving focus, and keyboard nav as a bare `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.
tabs TabItem[]
svelte
Svelte only: an array of `{ value, label, disabled? }` declaring the tabs; panel content comes from the `panel` snippet keyed by value.
lazy boolean false
svelte
Svelte only: mount a panel's `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.

Appearance

Variants

underline

.xtyle-tabs--underline

A minimal row with a moving underline under the selected tab. The default.

pill

.xtyle-tabs--pill

Tabs sit in a tinted track; the selected tab becomes a solid accent pill.

enclosed

.xtyle-tabs--enclosed

Folder-style tabs that connect to the panel; the selected tab joins the content surface.

Sizes

sm

.xtyle-tabs--sm

Compact.

md

default
.xtyle-tabs

Default.

States

selected

.xtyle-tabs__tab[aria-selected="true"]

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

.xtyle-tabs__tab:hover

Pointer over an unselected tab; overlay paints the hover tint and the text brightens.

active

.xtyle-tabs__tab:active::after

Tab pressed. Overlay paints the press tint.

focus-visible

.xtyle-tabs__tab: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

.xtyle-tabs__tab:disabled, .xtyle-tabs__tab[aria-disabled="true"]

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

.xtyle-tabs

The root wrapper carrying the variant and size classes; stacks the tablist over the active panel.

--space-3 --font-sans --fg-0

tablist

.xtyle-tabs__tablist

The horizontal row of tab triggers (role=tablist).

--space-1 --space-2 --bg-2 --line --border-thin --radius-lg

tab

.xtyle-tabs__tab

A single tab trigger (role=tab). Selected, hover, active, focus, and disabled states all live here.

--text-body --weight-medium --leading-tight --fg-2 --radius-md --space-2 --space-4 --border-thin --duration-fast --ease-standard

panel

.xtyle-tabs__panel

A content region (role=tabpanel) shown only when its tab is selected; focusable so keyboard users can scroll it.

--fg-1 --radius-sm

overlay

.xtyle-tabs__tab::after

The pseudo-element behind each tab that paints hover and active state tints.

--state-hover --state-press

Tokens & coverage

What the component consumes, checked live against what the algorithm produces.

Success:fully covered 33/33 consumed tokens produced default register: 299 tokens

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

tab
html astro

A tab trigger's label. Each slot="tab" element becomes a tab; its value attribute keys it and disabled marks it unselectable. (html / astro)

panel
html svelte 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

Implements the WAI-ARIA tabs pattern: role="tablist" / role="tab" / role="tabpanel" with aria-selected, aria-controls, and aria-labelledby wired automatically; in tablist mode the element owns no panels, so tabs omit aria-controls and stand as a bare tablist.
Roving tabindex: only the selected tab is in the tab order; arrow keys move between tabs, Home/End jump to the first/last enabled tab.
activation chooses automatic (select on focus) or manual (Enter/Space to select) per the WAI-ARIA guidance for cheap vs. expensive panels.
Disabled tabs are announced as disabled and skipped by arrow-key navigation.
Each panel is focusable (tabindex="0") so keyboard users can reach and scroll content with no focusable children.
The tablist REQUIRES an accessible name; provide label or labelledby. The binding warns at runtime when neither is set.
Focus is shown with a token ring and a transparent outline that the forced-colors base rule promotes to a real system outline.
Selection carries a non-color channel on demand: when the theme sets --selection-cue: marker, the selected tab gains a check glyph alongside the color, satisfying WCAG 1.4.1. High-contrast emits marker by default, and any algorithm can opt in via the cues knob.

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>