# Tabs

> Tier 1 delegated behavior: markup, activation modes, orientation, RTL, keyboard, ARIA relationships, SSR ids, automatic and manual registration.

Page: https://svelte-lean.vercel.app/primitives/tabs · Markdown: https://svelte-lean.vercel.app/primitives/tabs.md · Index: https://svelte-lean.vercel.app/llms.txt

Sections of one page that the reader switches between: ARIA tabs markup, with selection, arrow keys and activation modes handled by a shared behavior. Tier 1.

```svelte
<script lang="ts">
	// @svelte-lean/vite injects the behavior for a static data-slean="tabs" marker.
	import '@svelte-lean/styles/tabs.css';
	import { tabsParts } from '@svelte-lean/primitives/tabs';

	const tabs = [
		{ value: 'account', label: 'Account', text: 'Name, email and avatar.' },
		{ value: 'security', label: 'Security', text: 'Password, two-factor authentication and sessions.' },
		{ value: 'billing', label: 'Billing', text: 'Plan, invoices and payment method.' }
	];
	// Roles, ids, aria-controls / aria-labelledby, selection and the tab stop, from one id.
	const t = tabsParts('settings');
</script>

<div id="settings" data-slean="tabs" data-slean-value="account">
	<div {...t.list} aria-label="Settings">
		{#each tabs as tab (tab.value)}<button {...t.tab(tab.value, 'account')}>{tab.label}</button>{/each}
	</div>
	{#each tabs as tab (tab.value)}
		<div {...t.panel(tab.value, 'account')} tabindex="0">{tab.text}</div>
	{/each}
</div>
```

Click, or Tab into the list and use the arrow keys, Home and End. Under RTL the arrows swap.

## Examples

### Vertical, manual activation

```svelte
<script lang="ts">
	// tabs: the array of the first example
	const t = tabsParts('side', { orientation: 'vertical' });
</script>

<div
	id="side"
	data-slean="tabs"
	data-slean-value="account"
	data-slean-orientation="vertical"
	data-slean-activation="manual"
>
	<div {...t.list} aria-label="Settings">
		{#each tabs as tab (tab.value)}<button {...t.tab(tab.value, 'account')}>{tab.label}</button>{/each}
	</div>
	{#each tabs as tab (tab.value)}
		<div {...t.panel(tab.value, 'account')} tabindex="0">{tab.text}</div>
	{/each}
</div>
```

ArrowDown and ArrowUp move focus; Enter or Space selects.

### Application-rendered panel

```svelte
<script lang="ts">
	import { on } from 'svelte/events';
	import OpenOrders from './OpenOrders.svelte';
	import ShippedOrders from './ShippedOrders.svelte';
	import ReturnedOrders from './ReturnedOrders.svelte';

	const tabs = [
		{ value: 'open', label: 'Open' },
		{ value: 'shipped', label: 'Shipped' },
		{ value: 'returned', label: 'Returned' }
	];

	// slean:change is dispatched before the behavior writes and may be cancelled, so read the
	// root's data-slean-value in a microtask after it.
	let value = $state('open');
	const follow = (root: HTMLElement) =>
		on(root, 'slean:change', () => {
			queueMicrotask(() => (value = root.getAttribute('data-slean-value') ?? value));
		});
</script>

<div data-slean="tabs" data-slean-value="open" data-slean-render="app" {@attach follow}>
	<div role="tablist" aria-label="Orders" data-slean-part="list">
		{#each tabs as tab (tab.value)}
			<button
				type="button"
				id="orders-tab-{tab.value}"
				role="tab"
				aria-controls="orders-panel"
				aria-selected={tab.value === value}
				tabindex={tab.value === value ? 0 : -1}
				data-slean-part="trigger"
				data-slean-value={tab.value}
			>
				{tab.label}
			</button>
		{/each}
	</div>
	<!-- Every trigger controls this one panel; the behavior points aria-labelledby at the selected one. -->
	<div
		id="orders-panel"
		role="tabpanel"
		aria-labelledby="orders-tab-open"
		data-slean-part="panel"
		tabindex="0"
	>
		{#if value === 'open'}
			<OpenOrders />
		{:else if value === 'shipped'}
			<ShippedOrders />
		{:else}
			<ReturnedOrders />
		{/if}
	</div>
</div>
```

With data-slean-render="app" there is one panel and only the selected content exists in the DOM.

### Guarded change

```svelte
<script lang="ts">
	import { on } from 'svelte/events';
	import { activateTab, type TabsChangeDetail } from '@svelte-lean/primitives/tabs';
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/checkbox.css';

	let root: HTMLElement;
	let unsaved = $state(false);
	let status = $state('No change yet.');

	// slean:change bubbles from the root before the DOM is written; preventDefault() keeps
	// the current tab.
	const guard = (node: HTMLElement) =>
		on(node, 'slean:change', (event) => {
			const { value, previous } = (event as CustomEvent<TabsChangeDetail>).detail;
			if (unsaved) {
				event.preventDefault();
				status = `Stayed on ${previous}: the draft is not saved.`;
			} else {
				status = `${previous} → ${value}`;
			}
		});
</script>

<div data-slean="tabs" data-slean-value="account" bind:this={root} {@attach guard}>
	<!-- the list and panels of the settings tabs -->
</div>
<label>
	<input type="checkbox" data-slean="checkbox" bind:checked={unsaved} />
	Unsaved draft
</label>
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-size="small"
	onclick={() => activateTab(root, 'billing')}>
	Open billing
</button>
<p role="status">{status}</p>
```

A slean:change listener cancels the change; activateTab() goes through the same path.

### Counts and a disabled tab

```svelte
<script lang="ts">
	import '@svelte-lean/styles/badge.css';

	const tabs = [
		{ value: 'inbox', label: 'Inbox', count: 12 },
		{ value: 'drafts', label: 'Drafts', count: 3 },
		{ value: 'archive', label: 'Archive', disabled: true }
	];
</script>

<div data-slean="tabs" data-slean-value="inbox">
	<div role="tablist" aria-label="Mail" data-slean-part="list">
		{#each tabs as tab, i (tab.value)}
			<button
				type="button"
				id="mail-tab-{tab.value}"
				role="tab"
				aria-controls="mail-panel-{tab.value}"
				aria-selected={i === 0}
				aria-disabled={tab.disabled ? 'true' : undefined}
				tabindex={i === 0 ? 0 : -1}
				data-slean-part="trigger"
				data-slean-value={tab.value}
			>
				{tab.label}
				{#if tab.count}<span data-slean="badge" data-size="small">{tab.count}</span>{/if}
			</button>
		{/each}
	</div>
	<!-- one panel per tab, as in the settings example -->
</div>
```

An aria-disabled trigger stays focusable and is skipped by the arrow keys.

### Form value

```svelte
<script lang="ts">
	import { tabsParts } from '@svelte-lean/primitives/tabs';

	const periods = [
		{ value: 'monthly', label: 'Monthly', text: '€12 per seat, cancel any time.' },
		{ value: 'yearly', label: 'Yearly', text: '€120 per seat, two months free.' }
	];
	const t = tabsParts('period');
	// One state renders the root, the triggers, the panels and the hidden input; the behavior
	// writes the input on every change and dispatches input, so bind:value follows.
	let period = $state('monthly');
</script>

<form method="post">
	<div id="period" data-slean="tabs" data-slean-value={period}>
		<input {...t.native} name="billing" bind:value={period} />
		<div {...t.list} aria-label="Billing period">
			{#each periods as p (p.value)}<button {...t.tab(p.value, period)}>{p.label}</button>{/each}
		</div>
		{#each periods as p (p.value)}
			<div {...t.panel(p.value, period)} tabindex="0">{p.text}</div>
		{/each}
	</div>
	<p>billing = {period}</p>
	<button type="submit" data-slean="button">Continue</button>
</form>
```

The optional native part, a hidden input, carries the selected tab to the form and to bind:value.

### Looks

```svelte
<!-- data-size: small | medium (default) | large; data-color: primary (default) and the palette;
	data-full-width: the triggers share the list's width. -->
<div id="small" data-slean="tabs" data-slean-value="day" data-size="small">…</div>
<div id="colored" data-slean="tabs" data-slean-value="day" data-color="secondary">…</div>
<div id="wide" data-slean="tabs" data-slean-value="day" data-size="large" data-full-width>…</div>
```

The selected label and its indicator take data-color; data-size sets the height and the text.

## Helper or plain markup

Two ways to write the same markup: with `tabsParts()` or by hand. Both render the same HTML, which a test of this site compares; use either.

`With tabsParts()`

```svelte
<script lang="ts">
	import { tabsParts } from '@svelte-lean/primitives/tabs';
	const t = tabsParts('settings');
	const tabs = [
		{
			value: 'account',
			label: 'Account',
			body: 'Name and email.'
		},
		{
			value: 'billing',
			label: 'Billing',
			body: 'Plan and invoices.'
		}
	];
	const current = 'account';
</script>

<div id="settings" data-slean="tabs" data-slean-value={current}>
	<div {...t.list} aria-label="Settings">
		{#each tabs as tab (tab.value)}
			<button {...t.tab(tab.value, current)}>{tab.label}</button
			>
		{/each}
	</div>
	{#each tabs as tab (tab.value)}
		<div {...t.panel(tab.value, current)}>{tab.body}</div>
	{/each}
</div>
```

`Plain markup`

```html
<div id="settings" data-slean="tabs" data-slean-value="account">
	<div
		role="tablist"
		aria-label="Settings"
		data-slean-part="list"
	>
		<button
			type="button"
			id="settings-tab-account"
			role="tab"
			aria-controls="settings-panel-account"
			aria-selected="true"
			tabindex="0"
			data-slean-part="trigger"
			data-slean-value="account"
		>
			Account
		</button>
		<button
			type="button"
			id="settings-tab-billing"
			role="tab"
			aria-controls="settings-panel-billing"
			aria-selected="false"
			tabindex="-1"
			data-slean-part="trigger"
			data-slean-value="billing"
		>
			Billing
		</button>
	</div>
	<div
		id="settings-panel-account"
		role="tabpanel"
		aria-labelledby="settings-tab-account"
		data-slean-part="panel"
		data-slean-value="account"
	>
		Name and email.
	</div>
	<div
		id="settings-panel-billing"
		role="tabpanel"
		aria-labelledby="settings-tab-billing"
		data-slean-part="panel"
		data-slean-value="billing"
		hidden
	>
		Plan and invoices.
	</div>
</div>
```

### What the helper does

- One id in, every part id out: `tabsParts('settings')` writes `settings-tab-<value>` and `settings-panel-<value>`, each tab’s `aria-controls` and each panel’s `aria-labelledby`.
- Strings only: no handler, no state, no instance. The server and the browser render the same attributes, so hydration has nothing to patch.
- `tab(value, current)` and `panel(value, current)` write `aria-selected`, the roving `tabindex` and `hidden` from your current value: the attributes the behavior writes on activation, so rendering them from state never fights it.

### Points to watch

- Spread `t.list` on the tab list and name it yourself (`aria-label`); `t.tab()` goes on each `<button>`, `t.panel()` on each panel.
- It registers nothing and imports no stylesheet: the root's `id` and `data-slean="tabs"` stay in your markup, where `@svelte-lean/vite` finds them.
- Values are used in ids, so a value must not contain whitespace.
- Ids must be unique on the page. Hand-written markup works the same; the ids and the references between them are then yours to keep in step.

## Usage

Install `@svelte-lean/primitives`, `@svelte-lean/styles` and the Vite plugin. The plugin adds the register import to every Svelte file with a static `data-slean="tabs"`; without it, or with a dynamic marker, import the register module once yourself.

```bash
npm install @svelte-lean/primitives
```

```bash
npm install --save-dev @svelte-lean/vite
```

`vite.config.ts`

```ts
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteLean } from '@svelte-lean/vite';
import { defineConfig } from 'vite';

export default defineConfig({ plugins: [svelteLean(), sveltekit()] });
```

`without the plugin`

```ts
// Any module that runs once on the client, for example the root layout. Safe on the server,
// idempotent, and the same module the plugin injects.
import '@svelte-lean/primitives/tabs/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
import '@svelte-lean/styles/tabs.css';
```

Use tabs for sections of one page; sections that are separate pages are links in a `<nav>`. Every option is an attribute on the root:

`every option`

```html
<div
	data-slean="tabs"
	data-slean-value="account"
	data-slean-activation="manual"
	data-slean-orientation="vertical"
	data-slean-loop="false"
	dir="rtl"
>
```

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div data-slean="tabs" data-slean-value="…">` | – | yes | data-slean-value names the selected tab. Options: data-slean-activation, data-slean-orientation, data-slean-loop, data-slean-render="app". Looks: data-size, data-color, data-full-width. |
| list | `<div role="tablist" aria-label="…">` | `list` | yes | Exactly one, with a name. Set aria-orientation="vertical" together with the orientation option. |
| trigger | `<button type="button" role="tab" id="…" aria-controls="…">` | `trigger` | yes | One per tab, with data-slean-value. The selected one has aria-selected="true". |
| panel | `<div role="tabpanel" id="…" aria-labelledby="…">` | `panel` | yes | data-slean-value matching a trigger; hidden when not selected. With data-slean-render="app": exactly one, controlled by every trigger, content rendered by the application. Add tabindex="0" when it has no focusable content. |
| native | `<input type="hidden" name="…">` | `native` | no | Carries the selected value to a form and to bind:value: written (with input and change events) on every change, never read. |

Parts inside a nested tabs root belong to the nested root only; inner interactions never change the outer root.

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab`/`Shift+Tab` | anywhere | Enters the tablist on the tabindex="0" trigger (native) |
| `ArrowRight`/`ArrowLeft` | focus on a trigger, horizontal | Next or previous enabled trigger; swapped under dir="rtl" |
| `ArrowDown`/`ArrowUp` | focus on a trigger, vertical | Next or previous enabled trigger |
| `Home`/`End` | focus on a trigger | First or last enabled trigger |
| `Enter`/`Space` | focus on a trigger | Selects the focused trigger (required in manual mode) |

In automatic mode a move also selects. Keys with Alt, Ctrl or Meta are ignored, so browser shortcuts keep working. With `data-slean-loop="false"` the ends stop instead of wrapping.

## Styling

`tabs.css` styles the list, triggers and panels through their parts; the indicator is the trigger's border, colored on `[aria-selected="true"]`. The vertical orientation moves the list to the inline-start side. The tokens it reads and the states it targets:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | gap, padding-block |
| `--slean-border` | `var(--slean-neutral-6)` | box-shadow, border-inline-end |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap, padding-inline |
| `--slean-control-height-md` | `2.25rem` | min-block-size |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-color-main` | `var(--slean-primary-main)` | border-color, color |
| `--slean-control-height-sm` | `2rem` | min-block-size |
| `--slean-text-xs` | `0.75rem` | font-size |
| `--slean-control-height-lg` | `2.75rem` | min-block-size |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | padding-inline, padding-block, gap |
| `--slean-text-md` | `1rem` | font-size |
| `--slean-focus-ring-width` | `2px` | outline-offset |

State selectors the stylesheet targets, all from the platform or ARIA: `:disabled`, `:focus-visible`, `:hover`, `[aria-disabled="true"]`, `[aria-selected="true"]`.

Variant attributes: `data-size` (`small`, `large`); `data-full-width`; `data-slean-orientation` (`vertical`).

## Controlled integration

The root dispatches a bubbling, cancelable `slean:change` before the DOM is written, with `detail` of type `TabsChangeDetail` (`value`, `previous`); `preventDefault()` cancels the selection. `activateTab(root, value)` from `@svelte-lean/primitives/tabs` selects through the same path and returns `false` when cancelled. The guarded change example uses both.

> **Note** A Svelte adapter that binds the selected value is not built. Selection lives in `data-slean-value`: read it from the root or the event, write it with `activateTab()`.

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 1839 B brotli · 2052 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `role="tablist"`, `role="tab"`, `aria-selected`, `roving tabindex`, `hidden`
- **Shared listeners**: click, keydown
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `ARIA tabs markup (role=tablist, tab, tabpanel)`

The bytes are the production build of `@svelte-lean/primitives/tabs/register` including the shared kernel, from `packages/primitives/artifacts/size.json` (1839 B brotli · 2052 B gzip). Plugin and manual registration ship the same modules: the `manual-registration` fixture builds to a Svelte Lean chunk of 1839 B brotli, the `tabs-only` fixture with the plugin to 1839 B brotli.

The release gate of ADR 0004: 1000 roots on one page keep the runtime's account at one `click` and one `keydown` listener, counted by the runtime and by an independent wrapper around `addEventListener`. The [proof page](https://svelte-lean.vercel.app/proof.md) repeats that reading in your browser.

Nothing runs at hydration: no scan of the document, no per-root setup. The first event on a root is the first work done for it; development builds validate the markup once then. There is no `WeakMap`, observer or layout read in the behavior source. Time from navigation to a page with 1000 interactive roots: 68.6 ms (median of 5 loads, chromium 153.0.8010.12, AMD EPYC 9555P 64-Core Processor, 2026-10-05T07:11:03.593Z).

Why this implementation

The browser has no tabs element. The ARIA pattern needs a roving `tabindex`, arrow keys, Home and End, automatic or manual activation, and panels that follow the selection. Svelte Lean adds that and nothing around it: no component, no per-instance listener, no mirrored state. The shared router in `@svelte-lean/core` walks `composedPath()` to the nearest `data-slean="tabs"` root, and the handler reads and writes attributes (ADR 0004).

### The browser owns

- the tab order: Tab enters the list on the trigger with tabindex="0"
- the tab, tablist and tabpanel roles and their announcements
- the click and keydown events routed to the behavior
- hiding panels through the hidden attribute

### Svelte Lean owns

- selection on click, Enter and Space; arrow keys, Home and End; automatic and manual activation
- writing aria-selected, the roving tabindex, hidden and data-slean-value (with data-slean-render="app", the one panel's aria-labelledby instead of hidden)
- the cancelable slean:change event and activateTab()
- development validation of the markup
- tabs.css and the contract

Accessibility

- Roles: `tablist` (named), `tab`, `tabpanel`. `aria-controls` from trigger to panel, `aria-labelledby` from panel to trigger. Ids are authored, never generated (ADR 0002).
- State: `aria-selected` and the roving `tabindex` on triggers, `hidden` on panels, `data-slean-value` on the root. The runtime takes the root's value as current; if the authored `aria-selected` disagrees, the first activation corrects it and development builds warn.
- Focus: a click focuses the trigger (Safari does not by itself). In manual mode focus and `tabindex` move while the selection waits for Enter or Space. When a listener cancels a change, selection stays and keyboard focus still moves.
- Disabled: `aria-disabled="true"` on the root makes the router skip it. A `disabled` trigger is not focusable; an `aria-disabled="true"` one is focusable, ignored by click, Enter and Space, and skipped by arrows, Home and End.
- RTL: direction comes from the nearest `dir` attribute; `dir="auto"` is resolved through `:dir(rtl)`. Computed style is never read.
- Development validation warns about a missing or duplicated list, duplicate values, triggers without panels and panels without triggers, missing roles or relationships, a root value that matches no trigger, and an `aria-orientation` that disagrees with the option.
- With `data-slean-render="app"`: one `tabpanel` that every trigger's `aria-controls` names, labelled by the selected trigger; the behavior rewrites `aria-labelledby` on every change. Validation warns about a panel count other than one, a `hidden` panel, or a panel labelled by an unselected trigger.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `ARIA tabs roles and states (tablist, tab, tabpanel, aria-selected, aria-controls)` | ARIA 1.2, widely supported by assistive technology | Not applicable within the support policy |
| `Shared router: composedPath(), getAttribute, ES2022 output` | Widely available | No transpilation to an older target is provided |
| `:dir(rtl), only to resolve dir="auto"` | Newly available since December 2023 (Chrome 120, Safari 16.4, Firefox 49) | Guarded in packages/primitives/src/internal/direction.ts: dir="auto" resolves to left-to-right; explicit dir attributes work everywhere |

Without JavaScript

The authored state stays: the selected panel is visible, the others are hidden, and switching is impossible; the delayed-hydration test asserts it. Pages that must work without script can render the panels without `hidden` as a stacked layout, or link between pages.

Server rendering and hydration

The server renders the selected state as authored, with authored ids. The register module is safe to import on the server; without a `document` it waits for a browser runtime (`packages/primitives/tests/ssr.test.ts`). The playground's SSR spec finds `aria-selected`, `tabindex`, `hidden` and the root value as authored.

The register module evaluates before the component code and installs the two listeners. A click before it has loaded changes nothing; after hydration the delayed-hydration test presses ArrowRight and sees the selection move.

Compatibility

The behavior needs the ES2022 output the packages emit and `composedPath()`. `:dir()` only resolves `dir="auto"` and is guarded: where it is unsupported, `dir="auto"` resolves to left-to-right. The behavior is tested in Chromium; Firefox and WebKit runs do not exist yet. The policy is on [Browser support](https://svelte-lean.vercel.app/docs/browser-support.md).

Tests and source

- `packages/primitives/tests/tabs.test.ts` unit tests (happy-dom): click, arrows, Home/End, activation modes, disabled, RTL, nesting, cancellation, 1000 roots
- `apps/playground/tests/primitives/tabs.spec.ts` Playwright: real focus movement, :focus-visible, RTL, slean:change, a Svelte page that renders only the selected panel
- `apps/playground/tests/primitives/listeners.spec.ts` 1 root against 1000 roots: the listener count is the release gate
- `apps/playground/tests/primitives/nested.spec.ts` tabs inside a menu, a menu inside a panel, tabs inside a modal dialog
- `apps/playground/tests/bench/tabs.bench.spec.ts` startup and interaction timings of 1000 roots, written to bench.json
- `fixtures/tabs-only and fixtures/manual-registration` consumer builds: only the tabs registration ships; the manual import ships the same modules
- `apps/playground/tests/primitives/a11y.spec.ts` axe on every playground page and on open dialog, popover and menu states

- `packages/primitives/src/tabs/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/tabs/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/tabs/behavior.ts` the behavior definition and activateTab()
- `packages/primitives/src/tabs/register.ts` the registration module, the only side effect
- `packages/primitives/src/tabs/validate.ts` development validation messages
- `packages/styles/css/tabs.css` the optional stylesheet
