Skip to main content

Tabs

Info:Navigation html svelte astro Success: coverage 33/33

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

Live demo

live · @xoji/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.

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.

Props

8 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 `xoji-tabs::part(tablist) { top: … }`.
tabs TabItem[]
svelte
Svelte only: an array of `{ value, label, disabled? }` declaring the tabs; panel content comes from the `panel` snippet keyed by value.

Appearance

Variants

underline

.xoji-tabs--underline

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

pill

.xoji-tabs--pill

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

enclosed

.xoji-tabs--enclosed

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

Sizes

sm

.xoji-tabs--sm

Compact.

md

default
.xoji-tabs

Default.

States

selected

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

.xoji-tabs__tab:hover

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

active

.xoji-tabs__tab:active::after

Tab pressed. Overlay paints the press tint.

focus-visible

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

.xoji-tabs__tab:disabled, .xoji-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

.xoji-tabs

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

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

tablist

.xoji-tabs__tablist

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

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

tab

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

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

.xoji-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: 276 tokens

Live coverage check against the xoji-default register (derive(xojiDefault, { 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.
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).

<xoji-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>
</xoji-tabs>
<script lang="ts">
	import { Tabs } from "@xoji/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 "@xoji/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>