# Toolbar

> role="toolbar" with roving focus across its controls; the controls keep their own behavior.

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

A row of controls that is one tab stop with arrow keys between them, for formatting bars and editor controls. Tier 1.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register imports are injected for the static data-slean
	// markers: toolbar, toggle and toggle-group.
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/select.css';
	import '@svelte-lean/styles/toggle.css';
	import '@svelte-lean/styles/toggle-group.css';
	import '@svelte-lean/styles/toolbar.css';
</script>

<div data-slean="toolbar" role="toolbar" aria-label="Formatting">
	<button type="button" data-slean="toggle" data-size="small" aria-pressed="false">Bold</button>
	<button type="button" data-slean="toggle" data-size="small" aria-pressed="false">Italic</button>
	<div role="separator" aria-orientation="vertical"></div>
	<div
		data-slean="toggle-group"
		role="group"
		aria-label="Alignment"
		data-slean-required
	>
		<button type="button" data-slean-part="item" data-slean-value="left" aria-pressed="true">
			Left
		</button>
		<button type="button" data-slean-part="item" data-slean-value="center" aria-pressed="false">
			Center
		</button>
		<button type="button" data-slean-part="item" data-slean-value="right" aria-pressed="false">
			Right
		</button>
	</div>
	<div role="separator" aria-orientation="vertical"></div>
	<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-size="small">Clear</button>
	<a href="/primitives/toolbar#keyboard" data-slean="button" data-variant="text">Keys</a>
	<select data-slean="select" data-size="small" aria-label="Font size">
		<option>12</option>
		<option selected>14</option>
		<option>16</option>
	</select>
</div>
```

Tab in, then use the arrow keys, Home and End; they cross the toggle group and stop at the select, which keeps its own keys.

## Examples

### Bound to content

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

	let bold = $state(false);
	let italic = $state(false);
	let align = $state('left');

	// One listener on the toolbar: slean:change bubbles from every toggle and group inside it.
	const follow = (node: HTMLElement) =>
		on(node, 'slean:change', (event) => {
			const target = event.target as HTMLElement;
			const { detail } = event as CustomEvent;
			if (target.dataset.format === 'bold') bold = detail.value;
			if (target.dataset.format === 'italic') italic = detail.value;
			if (target.dataset.slean === 'toggle-group') align = detail.value[0] ?? align;
		});
</script>

<div data-slean="toolbar" role="toolbar" aria-label="Note formatting" {@attach follow}>
	<button type="button" data-slean="toggle" data-size="small" data-format="bold" aria-pressed="false">
		Bold
	</button>
	<button type="button" data-slean="toggle" data-size="small" data-format="italic" aria-pressed="false">
		Italic
	</button>
	<div role="separator" aria-orientation="vertical"></div>
	<div data-slean="toggle-group" role="group" aria-label="Alignment"
		data-slean-required>
		<button type="button" data-slean-part="item" data-slean-value="left" aria-pressed="true">Left</button>
		<button type="button" data-slean-part="item" data-slean-value="center" aria-pressed="false">Center</button>
		<button type="button" data-slean-part="item" data-slean-value="right" aria-pressed="false">Right</button>
	</div>
</div>
<p style:font-weight={bold ? 700 : 400} style:font-style={italic ? 'italic' : 'normal'}
	style:text-align={align}>
	Meeting notes: ship the toolbar page on Friday.
</p>
```

One slean:change listener on the toolbar serves every toggle and group inside it.

### Vertical

```svelte
<div
	data-slean="toolbar"
	role="toolbar"
	aria-label="Drawing tools"
	data-slean-orientation="vertical"
	aria-orientation="vertical"
>
	<button type="button" data-slean="toggle" data-size="small" aria-pressed="true">Select</button>
	<button type="button" data-slean="toggle" data-size="small" aria-pressed="false">Pen</button>
	<button type="button" data-slean="toggle" data-size="small" aria-pressed="false" aria-disabled="true">
		Eraser
	</button>
	<div role="separator"></div>
	<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-size="small">Undo</button>
</div>
```

ArrowDown and ArrowUp move; the aria-disabled Eraser is skipped and stays focusable.

### Tab order in the markup

```svelte
<!-- No tabindex: every control is reachable by Tab without JavaScript; with it, the
     first focus makes the focused control the tab stop and sets the others to -1. -->
<div data-slean="toolbar" role="toolbar" aria-label="Clipboard, no tabindex">
	<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-size="small">Cut</button>
	<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-size="small">Copy</button>
	<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-size="small">Paste</button>
</div>

<!-- Authored roving tabindex: one tab stop from the first paint; without JavaScript only
     the first control is reachable by Tab. -->
<div data-slean="toolbar" role="toolbar" aria-label="Clipboard, roving tabindex">
	<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-size="small" tabindex="0">Cut</button>
	<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-size="small" tabindex="-1">Copy</button>
	<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-size="small" tabindex="-1">Paste</button>
</div>
```

Without tabindex the page stays usable by keyboard without JavaScript; an authored roving tabindex makes one tab stop from the first paint.

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="toolbar"`; without it, import the register module once.

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

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/toolbar/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/toggle.css';
import '@svelte-lean/styles/toggle-group.css';
import '@svelte-lean/styles/toolbar.css';
```

Wrap the controls in `role="toolbar"` with a name; a vertical toolbar sets `data-slean-orientation="vertical"` and `aria-orientation="vertical"` together. Controls need no marker: buttons, links, checkboxes, button-like inputs and elements with `tabindex`. Content in a popover, dialog, hidden or inert element is not part of it.

Following WAI-ARIA, keep text fields and selects few and at the end. `toolbarControls(root)` from `@svelte-lean/primitives/toolbar` lists the controls the arrow keys move between.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div data-slean="toolbar" role="toolbar" aria-label="…">` | – | yes | Options: data-slean-orientation (horizontal, vertical; set aria-orientation too), -loop. The controls are found, not marked. |
| controls | `<button>, <a href>, <input type="checkbox">, toggles, toggle-group items, [tabindex]` | – | yes | The roving controls, in document order. Disabled ones are skipped by the keys. |
| separate controls | `<select>, text <input>, <textarea>, sliders, radios, composite widgets` | – | no | Keep their keys and their own tab stop. Content in a popover, dialog, hidden or inert element is not part of the toolbar. |
| separator | `<div role="separator">` | – | styles only | A rule across the bar between groups of controls. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab`/`Shift+Tab` | entering or leaving | Enters on the tab stop, then leaves the toolbar (native) |
| `ArrowRight`/`ArrowLeft` | horizontal, focus on a control | Next or previous enabled control, looping; swapped under dir="rtl" |
| `ArrowDown`/`ArrowUp` | vertical, focus on a control | Next or previous enabled control |
| `Home`/`End` | focus on a control | First or last enabled control |
| `Enter`/`Space` | focus on a control | The control’s own activation (native) |
| `ArrowLeft`/`ArrowRight`/`Home`/`End` | focus in a text field or select | Left to the field: caret movement or the select’s own keys |

## Styling

`toolbar.css` lays out the bar on one surface, draws `role="separator"` as a rule across it, and flattens a nested toggle group and unpressed toggles into the bar. The controls bring their own stylesheets; load `toolbar.css` after `toggle.css` and `toggle-group.css`. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | gap, padding, margin-block, margin-inline |
| `--slean-border` | `var(--slean-neutral-6)` | border, background |
| `--slean-radius-md` | `var(--slean-radius)` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap |

State selectors the stylesheet targets, all from the platform or ARIA: `[aria-pressed="true"]`, `[role="separator"]`.

Variant attributes: `data-slean-orientation` (`vertical`).

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 1706 B brotli · 1920 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `role="toolbar"`, `aria-orientation`, `focusin`, `:dir(rtl)`
- **Shared listeners**: keydown, focusin
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `role="toolbar"`

The toolbar registers one `keydown` and one `focusin` handler with the shared router; the toggles inside add the shared `click` handler. A thousand toolbars keep one listener per type (`tests/toolbar.test.ts`). Its bytes are the production registration with the core kernel.

Why this implementation

`role="toolbar"` tells assistive technology that a row of controls belongs together, but the platform gives it no behavior: every button is a separate tab stop. The WAI-ARIA toolbar pattern makes the row one tab stop with arrow keys between the controls, so a keyboard user passes a toolbar with one Tab.

The toolbar owns focus movement and nothing else. It finds its controls in the DOM (buttons, links, checkboxes, toggles, toggle group items), writes `tabindex` on them and moves focus. A toggle still toggles, a link still navigates, and a toggle group inside hands its arrow keys to the toolbar so the arrows can leave it.

A control that uses the arrow keys itself, such as a text field, a select or a slider, keeps them and keeps its own tab stop. Moving the toolbar’s tab stop onto such a control would leave no key to return to the other controls.

### The browser owns

- every control’s own behavior: buttons, links, checkboxes, selects and text fields
- Tab and Shift+Tab into and out of the toolbar
- announcing the toolbar name and orientation (screen readers)
- the keydown and focusin events routed to the behavior

### Svelte Lean owns

- one tab stop across the toolbar’s controls (roving tabindex), set on the first focus
- arrow keys by orientation, Home and End, RTL, skipping disabled controls
- leaving the keys of text fields, selects and nested composite widgets alone
- toolbarControls(), development validation
- toolbar.css: the bar, separators, a flattened toggle group

Accessibility

- `role="toolbar"` with a name; `aria-orientation="vertical"` for a vertical toolbar, which development validation checks against `data-slean-orientation`.
- One control is in the tab order. Focus by click, by Tab or returned by a closing popover makes that control the tab stop, so Tab comes back to where the user was.
- The arrow keys, Home and End skip `disabled` and `aria-disabled` controls. Enter and Space are each control’s own activation.
- A text field keeps ArrowLeft, ArrowRight, Home and End for its caret; a select and a slider keep their keys. Keys a nested widget already handled, such as a menu, are left to it.
- `role="separator"` between groups of controls is drawn by the stylesheet and is not focusable.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `role="toolbar", aria-orientation` | ARIA 1.2, announced by screen readers | Not applicable |
| `focusin` | Widely available | Not applicable |
| `:dir()` | Baseline 2023 | dir="auto" resolves to left-to-right; explicit dir attributes work |

Without JavaScript

Every control works on its own: buttons, links, checkboxes and selects are native. Without an authored `tabindex` every control is a tab stop and the arrow keys do nothing; a toggle inside does not change.

Server rendering and hydration

Render the controls with or without the roving `tabindex` (see the tab order example). No ids are generated, and the register module is safe to import on the server.

Before the behavior loads, every control without an authored `tabindex` is a tab stop and the arrow keys scroll. The first focus after hydration sets the tab stop; nothing runs before it.

Compatibility

In Safari and in Firefox on macOS a click does not focus a button, so a click on a plain button or toggle leaves the tab stop where it was; a toggle group item focuses itself on click. `dir="auto"` needs `:dir()` (Baseline 2023) to resolve; explicit `dir` attributes work everywhere.

Tests and source

- `packages/primitives/tests/toolbar.test.ts` Vitest: which controls rove, arrow keys across a toggle group, separate controls, RTL, nested toolbars, 1000 toolbars
- `apps/playground/tests/primitives/toggles.spec.ts` Playwright: toggle, toggle group and toolbar with real focus and keys, and an axe scan
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/toolbar/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/toolbar/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/internal/toolbar-controls.ts` which descendants are roving controls and which keep their keys
- `packages/primitives/src/toolbar/behavior.ts` the behavior definition
- `packages/primitives/src/toolbar/register.ts` the registration module
- `packages/styles/css/toolbar.css` the optional stylesheet
