Skip to main content
xriptxr

Tabs

Info:Navigation
35

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.

Three variants

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

variant="underline"

The default — a quiet underline marks the selected tab.

variant="pill"

A filled pill carries the selection.

variant="enclosed"

Folder-style tabs that connect to the panel.

Labels carry markup

A tab label is a render slot, not a string: an icon or a count badge sits beside the text and survives into the static render.

Twelve unread, and the badge rides in the tab itself.

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.

More tabs than room

Info:overflow

Both strips hold the same eighteen tabs in the same narrow box. wrap is the default: the tabs flow onto as many rows as they need and every one stays visible. scroll keeps them on a single row behind a scrollbar.

overflow="wrap"

Overview panel.

overflow="scroll"

Overview panel.

Filling a bounded box

Info:fill

The strip stays put and the panel scrolls. Without fill a panel taller than the box grows past it and the overflow escapes to whatever ancestor clips, with no scrollbar to say so.

12:04:00 build resolving manifest 1 of 24

12:04:01 build resolving manifest 2 of 24

12:04:02 build resolving manifest 3 of 24

12:04:03 build resolving manifest 4 of 24

12:04:04 build resolving manifest 5 of 24

12:04:05 build resolving manifest 6 of 24

12:04:06 build resolving manifest 7 of 24

12:04:07 build resolving manifest 8 of 24

12:04:08 build resolving manifest 9 of 24

12:04:09 build resolving manifest 10 of 24

12:04:10 build resolving manifest 11 of 24

12:04:11 build resolving manifest 12 of 24

12:04:12 build resolving manifest 13 of 24

12:04:13 build resolving manifest 14 of 24

12:04:14 build resolving manifest 15 of 24

12:04:15 build resolving manifest 16 of 24

12:04:16 build resolving manifest 17 of 24

12:04:17 build resolving manifest 18 of 24

12:04:18 build resolving manifest 19 of 24

12:04:19 build resolving manifest 20 of 24

12:04:20 build resolving manifest 21 of 24

12:04:21 build resolving manifest 22 of 24

12:04:22 build resolving manifest 23 of 24

12:04:23 build resolving manifest 24 of 24

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.

Tabs re-keys the children it adopts (tab becomes label-N, panel becomes panel-N) so its shadow can address each pair, which means a child's slot attribute belongs to the component after first adoption. A framework that reconciles attributes must leave slot alone; re-asserting the authored value un-matches the shadow's named slot and every child falls out of the component - right on the first paint, wrong on every repaint after, and silent either way.
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

13 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 TabsSize
sm md
md
html svelte astro
Tab trigger size.
overflow TabsOverflow
wrap scroll
wrap
html svelte astro
What a strip too wide for its container does. 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.
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: … }.
fill boolean false
html svelte astro
Takes the remaining height of a bounded parent and gives it to the panel region, which scrolls; the tab strip stays put. Without it a panel taller than the host grows the panels area past the host and the overflow escapes to whatever ancestor clips — silently, with no scrollbar. Setting overflow on the panel from outside cannot fix that, because the panel is a grid item of an area that is itself unbounded.
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. The same list is items on every other binding.
items TabItem[]
html astro
The tabs as data instead of authored [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.
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.

Events

What the component emits, and what rides along on event.detail. The name in the first column is the one addEventListener takes.

EventDetailBindingsDescription
change { value, index }
html svelte astro
The selected tab changed.

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. 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

Success:::part(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

Success:::part(tablist) .xtyle-tabs__tablist

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

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

tab

Success:::part(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

panels

Success:::part(panels) .xtyle-tabs__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

Success:::part(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

internal .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 35/35 consumed tokens produced default register: 310 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 --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-medium

Slots

tab
html astro

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)

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

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>

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>

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>