sveltelean Primitives
Versionv0.2.0 GitHub

Example

Settings tabs
Name, email and avatar.
<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>
Click, or Tab into the list and use ArrowLeft and ArrowRight, Home and End. Under RTL the arrows swap. The HTML tab is what any file renders; the Svelte tab adds the registration import that @svelte-lean/vite writes for you when the marker is static.

Last slean:change on the example: No change yet.

Vertical orientation, manual activation

Vertical tabs with manual activation
Name, email and avatar.
<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>
ArrowDown and ArrowUp move focus without selecting; Enter or Space selects. aria-orientation on the list tells screen readers which keys apply and must agree with data-slean-orientation (development validation warns when it does not).

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/primitives
npm install --save-dev @svelte-lean/vite
npm install @svelte-lean/styles

With 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
// 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()] });
the manual escape hatch
// 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';
app.css or +layout.svelte
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:

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

Anatomy

PartElementdata-slean-partRequiredNotes
root<div data-slean="tabs" data-slean-value="…">–yesdata-slean-value names the selected tab. Options: data-slean-activation, data-slean-orientation, data-slean-loop.
list<div role="tablist" aria-label="…">listyesExactly one, with a name. Set aria-orientation="vertical" together with the orientation option.
trigger<button type="button" role="tab" id="…" aria-controls="…">triggeryesOne per tab, with data-slean-value. The selected one has aria-selected="true".
panel<div role="tabpanel" id="…" aria-labelledby="…">panelyesdata-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: tablist on the list (with a name), tab on every trigger, tabpanel on every panel. Relationships: aria-controls from trigger to panel id, aria-labelledby from panel to trigger id. 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 DOM is the state; the runtime takes the root's value as current and never scans aria-selected. If the authored aria-selected disagrees 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 tabindex move while the selection stays until Enter or Space. When a slean:change listener cancels, selection and tabindex stay; keyboard focus still moves.
  • Disabled: data-slean-disabled or aria-disabled="true" on the root makes the router skip it. A trigger with disabled is not focusable; with aria-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 dir attribute; 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 an aria-orientation that disagrees with the option.

Keyboard

KeyWhenResult
Tab/Shift+TabanywhereEnters the tablist on the tabindex="0" trigger (native)
ArrowRight/ArrowLeftfocus on a trigger, horizontalNext or previous enabled trigger; swapped under dir="rtl"
ArrowDown/ArrowUpfocus on a trigger, verticalNext or previous enabled trigger
Home/Endfocus on a triggerFirst or last enabled trigger
Enter/Spacefocus on a triggerSelects 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

FeatureBaselineOutside the target
ARIA tabs roles and states (tablist, tab, tabpanel, aria-selected, aria-controls)ARIA 1.2, widely supported by assistive technologyNot applicable within the support policy
Shared router: composedPath(), getAttribute, ES2022 outputWidely availableNo 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:

TokenDefault (light)Applies to
--slean-space-10.25remgap, padding-block
--slean-bordervar(--slean-neutral-6)box-shadow, border-inline-end
--slean-space-20.5remgap
--slean-control-height-md2.25remmin-block-size
--slean-space-30.75rempadding-inline
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-text-sm0.875remfont-size
--slean-font-weight-medium500font-weight
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-fgvar(--slean-neutral-12)color
--slean-accentoklch(54% 0.19 258)border-color
--slean-focus-ring-width2pxoutline-offset
--slean-space-41rempadding-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.

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

Source