Primitives Navigation
Tabs
ARIA tabs markup driven by a delegated behavior: one click and one keydown listener on the document serve every tabs root on the page, and the state lives in aria-selected, tabindex and hidden. The author renders the initial state; the behavior handles selection, arrow keys, Home and End, activation modes and disabled triggers. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 1521 B brotli · 1693 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - 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)
On this page
Example
<div 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-security"
role="tab"
aria-controls="settings-panel-security"
aria-selected="false"
tabindex="-1"
data-slean-part="trigger"
data-slean-value="security"
>
Security
</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"
tabindex="0"
>
Name, email and avatar.
</div>
<div
id="settings-panel-security"
role="tabpanel"
aria-labelledby="settings-tab-security"
data-slean-part="panel"
data-slean-value="security"
tabindex="0"
hidden
>
Password, two-factor authentication and sessions.
</div>
<div
id="settings-panel-billing"
role="tabpanel"
aria-labelledby="settings-tab-billing"
data-slean-part="panel"
data-slean-value="billing"
tabindex="0"
hidden
>
Plan, invoices and payment method.
</div>
</div><script lang="ts">
// With @svelte-lean/vite this import is injected for the static data-slean="tabs" marker.
// Without the plugin, write it yourself once; both paths register the same behavior.
import '@svelte-lean/primitives/tabs/register';
import '@svelte-lean/styles/tabs.css';
</script>
<div 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-security"
role="tab"
aria-controls="settings-panel-security"
aria-selected="false"
tabindex="-1"
data-slean-part="trigger"
data-slean-value="security"
>
Security
</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"
tabindex="0"
>
Name, email and avatar.
</div>
<div
id="settings-panel-security"
role="tabpanel"
aria-labelledby="settings-tab-security"
data-slean-part="panel"
data-slean-value="security"
tabindex="0"
hidden
>
Password, two-factor authentication and sessions.
</div>
<div
id="settings-panel-billing"
role="tabpanel"
aria-labelledby="settings-tab-billing"
data-slean-part="panel"
data-slean-value="billing"
tabindex="0"
hidden
>
Plan, invoices and payment method.
</div>
</div>Last slean:change on the example: No change yet.
Vertical orientation, manual activation
<div
data-slean="tabs"
data-slean-value="account"
data-slean-orientation="vertical"
data-slean-activation="manual"
>
<div role="tablist" aria-label="Settings" aria-orientation="vertical" data-slean-part="list">
<button
type="button"
id="side-tab-account"
role="tab"
aria-controls="side-panel-account"
aria-selected="true"
tabindex="0"
data-slean-part="trigger"
data-slean-value="account"
>
Account
</button>
<button
type="button"
id="side-tab-security"
role="tab"
aria-controls="side-panel-security"
aria-selected="false"
tabindex="-1"
data-slean-part="trigger"
data-slean-value="security"
>
Security
</button>
<button
type="button"
id="side-tab-billing"
role="tab"
aria-controls="side-panel-billing"
aria-selected="false"
tabindex="-1"
data-slean-part="trigger"
data-slean-value="billing"
>
Billing
</button>
</div>
<div
id="side-panel-account"
role="tabpanel"
aria-labelledby="side-tab-account"
data-slean-part="panel"
data-slean-value="account"
tabindex="0"
>
Name, email and avatar.
</div>
<div
id="side-panel-security"
role="tabpanel"
aria-labelledby="side-tab-security"
data-slean-part="panel"
data-slean-value="security"
tabindex="0"
hidden
>
Password, two-factor authentication and sessions.
</div>
<div
id="side-panel-billing"
role="tabpanel"
aria-labelledby="side-tab-billing"
data-slean-part="panel"
data-slean-value="billing"
tabindex="0"
hidden
>
Plan, invoices and payment method.
</div>
</div><script lang="ts">
import '@svelte-lean/primitives/tabs/register';
import '@svelte-lean/styles/tabs.css';
</script>
<div
data-slean="tabs"
data-slean-value="account"
data-slean-orientation="vertical"
data-slean-activation="manual"
>
<div role="tablist" aria-label="Settings" aria-orientation="vertical" data-slean-part="list">
<button
type="button"
id="side-tab-account"
role="tab"
aria-controls="side-panel-account"
aria-selected="true"
tabindex="0"
data-slean-part="trigger"
data-slean-value="account"
>
Account
</button>
<button
type="button"
id="side-tab-security"
role="tab"
aria-controls="side-panel-security"
aria-selected="false"
tabindex="-1"
data-slean-part="trigger"
data-slean-value="security"
>
Security
</button>
<button
type="button"
id="side-tab-billing"
role="tab"
aria-controls="side-panel-billing"
aria-selected="false"
tabindex="-1"
data-slean-part="trigger"
data-slean-value="billing"
>
Billing
</button>
</div>
<div
id="side-panel-account"
role="tabpanel"
aria-labelledby="side-tab-account"
data-slean-part="panel"
data-slean-value="account"
tabindex="0"
>
Name, email and avatar.
</div>
<div
id="side-panel-security"
role="tabpanel"
aria-labelledby="side-tab-security"
data-slean-part="panel"
data-slean-value="security"
tabindex="0"
hidden
>
Password, two-factor authentication and sessions.
</div>
<div
id="side-panel-billing"
role="tabpanel"
aria-labelledby="side-tab-billing"
data-slean-part="panel"
data-slean-value="billing"
tabindex="0"
hidden
>
Plan, invoices and payment method.
</div>
</div>Why this implementation exists
The browser has no tabs element. The ARIA pattern needs a roving tabindex, arrow
keys that move between triggers, Home and End, a choice between automatic and manual activation,
and panels that hide and show with the selection. That is behavior the platform does not
provide, so Svelte Lean adds exactly that and nothing around it: no component, no per-instance
listener, no mirrored state. The behavior is registered once per page; 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
- the cancelable slean:change event and activateTab()
- development validation of the markup
- tabs.css and the contract
Usage
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesnpm install --save-dev @svelte-lean/vitepnpm add -D @svelte-lean/viteyarn add -D @svelte-lean/vitebun add -d @svelte-lean/vitenpm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesWith the Vite plugin, every static data-slean="tabs" in a Svelte file gets import '@svelte-lean/primitives/tabs/register' appended to its compiled module.
Without the plugin, with a dynamic marker (data-slean={name}) or in a
build that is not Vite, write that import yourself once. Both paths ship the same modules: the manual-registration fixture builds to a Svelte Lean chunk of 1521 B brotli, the tabs-only fixture with the plugin to 1521 B brotli.
// 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()] });// 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';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 that the reader switches between without navigating. Sections
that are separate pages are links in a <nav>, not tabs. The options are
attributes on the root, one per option:
<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. |
| 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. Add tabindex="0" when it has no focusable content. |
Parts inside a nested tabs root belong to the nested root only; the handlers check closestRoot() before acting, and the nested test suite proves that inner interactions
never change the outer root.
Runtime profile
The bytes in the runtime block are the production build of @svelte-lean/primitives/tabs/register including the shared kernel, from packages/primitives/artifacts/size.json (1521 B brotli · 1693 B gzip); the file also records
the enforced budget, and the esbuild figure kept for comparison. The listeners are the release
gate of ADR 0004: 1000 roots on one page keep the runtime's account at one click and one keydown, counted by the runtime and by an independent
wrapper around addEventListener, and the last root is interactive by pointer and
keyboard. The proof page 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, and development builds validate the markup once on that event.
There is no WeakMap, no observer and no layout read in the behavior source
(asserted by the unit tests). The playground's bench project records the time from navigation to
a page with 1000 roots being interactive: 98.8 ms (median of 5 loads, chromium 153.0.8010.53, Apple M2,
2026-09-27T20:46:17.407Z).
Accessibility contract
- Roles:
tabliston the list (with a name),tabon every trigger,tabpanelon every panel. Relationships:aria-controlsfrom trigger to panel id,aria-labelledbyfrom panel to trigger id. Ids are authored, never generated (ADR 0002). - State:
aria-selectedand the rovingtabindexon triggers,hiddenon panels,data-slean-valueon the root. The DOM is the state; the runtime takes the root's value as current and never scansaria-selected. If the authoredaria-selecteddisagrees with the root value, the first activation corrects it and development builds warn. - Focus: roving tabindex. A click focuses the trigger (Safari does not focus buttons on click by
itself). In manual mode focus and
tabindexmove while the selection stays until Enter or Space. When aslean:changelistener cancels, selection andtabindexstay; keyboard focus still moves. - Disabled:
data-slean-disabledoraria-disabled="true"on the root makes the router skip it. A trigger withdisabledis not focusable; witharia-disabled="true"it is focusable, ignored by click, Enter and Space, and skipped by arrows, Home and End. - RTL: direction is read from the nearest
dirattribute;dir="auto"is resolved through:dir(rtl). Computed style is never read. - Development validation warns about: not exactly one list, a list without
role="tablist", duplicate trigger values, triggers without a panel and panels without a trigger, missing roles or relationships, a root without a value or with a value that matches no trigger, and anaria-orientationthat disagrees with the option.
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; handled keys call preventDefault(). With data-slean-loop="false" the ends stop instead of wrapping and the key is still consumed.
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. This is asserted, not hidden: the delayed-hydration test clicks a trigger before any
script has loaded and checks that nothing changed. Pages that must work without script can
render the panels without hidden and accept a stacked layout, or link between pages.
Server rendering
Everything is static markup: the server renders the selected state exactly as authored, with
authored ids. The register module is safe to import on the server; without a document the registration waits until a browser runtime exists (packages/primitives/tests/ssr.test.ts). The playground's SSR spec reads the server HTML and finds aria-selected, tabindex, hidden and the root value as authored.
Before hydration
The register module is an ES module import, so it evaluates before the component code and installs the two listeners when the first behavior registers. A click that arrives before that module has loaded changes nothing, which the delayed-hydration test asserts. When the scripts arrive, hydration leaves the authored state untouched and the behavior is live: the same test then presses ArrowRight and sees the selection move.
Styling
tabs.css styles the list, the triggers and the 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 | 0.25rem | gap, padding-block |
--slean-border | var(--slean-neutral-6) | box-shadow, border-inline-end |
--slean-space-2 | 0.5rem | gap |
--slean-control-height-md | 2.25rem | min-block-size |
--slean-space-3 | 0.75rem | 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-accent | oklch(54% 0.19 258) | border-color |
--slean-focus-ring-width | 2px | outline-offset |
--slean-space-4 | 1rem | padding-block, gap |
State selectors the stylesheet targets, all from the platform or ARIA: :disabled, :focus-visible, :hover, [aria-disabled="true"], [aria-selected="true"].
Variant attributes: data-slean-orientation (vertical).
Controlled integration
What exists today: the root dispatches a bubbling, cancelable slean:change CustomEvent 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 programmatically through the same path and
returns false when cancelled. An application listens with addEventListener, as the status line under the example does.
<script lang="ts">
import { activateTab, type TabsChangeDetail } from '@svelte-lean/primitives/tabs';
let root: HTMLElement;
let subscribed = $state(false);
$effect(() => {
// slean:change bubbles from the root before the DOM is written; preventDefault() keeps
// the current tab. The selected value stays in data-slean-value, not in a store.
const onChange = (event: Event) => {
const { value, previous } = (event as CustomEvent<TabsChangeDetail>).detail;
if (value === 'billing' && !subscribed) event.preventDefault();
console.log(previous, '->', value);
};
root.addEventListener('slean:change', onChange);
return () => root.removeEventListener('slean:change', onChange);
});
// Programmatic selection goes through the same path and returns false when cancelled.
const showBilling = () => activateTab(root, 'billing');
</script>
<div data-slean="tabs" data-slean-value="account" bind:this={root}>…</div>Note A Svelte adapter (an action, attachment or component that binds the selected value) is not
built. Selection lives in data-slean-value; read it from the root or from the event
and write it with activateTab().
Compatibility notes
The behavior needs the ES2022 output the packages emit and the shared router's composedPath(); no transpilation to an older target is provided. :dir() is used only to resolve dir="auto" and is guarded, so where it is unsupported dir="auto" resolves to left-to-right and explicit dir attributes work
everywhere. The behavior is tested in Chromium; Firefox and WebKit runs do not exist yet. The
policy is on Browser support.
Testing
packages/primitives/tests/tabs.test.tsunit tests (happy-dom): click, arrows, Home/End, activation modes, disabled, RTL, nesting, cancellation, 1000 rootsapps/playground/tests/primitives/tabs.spec.tsPlaywright: real focus movement, :focus-visible, RTL, slean:changeapps/playground/tests/primitives/listeners.spec.ts1 root against 1000 roots: the listener count is the release gateapps/playground/tests/primitives/nested.spec.tstabs inside a menu, a menu inside a panel, tabs inside a modal dialogapps/playground/tests/bench/tabs.bench.spec.tsstartup and interaction timings of 1000 roots, written to bench.jsonfixtures/tabs-only and fixtures/manual-registrationconsumer builds: only the tabs registration ships; the manual import ships the same modulesapps/playground/tests/primitives/a11y.spec.tsaxe on every playground page and on open dialog, popover and menu states
Source
packages/primitives/src/tabs/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/tabs/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/tabs/behavior.tsthe behavior definition and activateTab()packages/primitives/src/tabs/register.tsthe registration module, the only side effectpackages/primitives/src/tabs/validate.tsdevelopment validation messagespackages/styles/css/tabs.cssthe optional stylesheet