Primitives Actions
Toolbar
One tab stop for a row of controls, with arrow keys between them. Each control keeps its own behavior, and a text field or select keeps its own keys. The only state is the roving tabindex. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 1494 B brotli · 1671 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
role="toolbar",aria-orientation,focusin,:dir(rtl)- Shared listeners
- keydown, focusin
- Per-instance listeners
- none
- Lazy state
- none
- Native base
role="toolbar"
On this page
Example
<div data-slean="toolbar" role="toolbar" aria-label="Formatting">
<button type="button" data-slean="toggle" data-size="sm" aria-pressed="false">Bold</button>
<button type="button" data-slean="toggle" data-size="sm" aria-pressed="false">Italic</button>
<div role="separator" aria-orientation="vertical"></div>
<div
data-slean="toggle-group"
role="group"
aria-label="Alignment"
data-slean-type="single"
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="ghost" data-size="sm">Clear</button>
<a href="/primitives/toolbar#keyboard" data-slean="button" data-variant="link">Keys</a>
<select data-slean="select" data-size="sm" aria-label="Font size">
<option>12</option>
<option selected>14</option>
<option>16</option>
</select>
</div><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="sm" aria-pressed="false">Bold</button>
<button type="button" data-slean="toggle" data-size="sm" aria-pressed="false">Italic</button>
<div role="separator" aria-orientation="vertical"></div>
<div
data-slean="toggle-group"
role="group"
aria-label="Alignment"
data-slean-type="single"
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="ghost" data-size="sm">Clear</button>
<a href="/primitives/toolbar#keyboard" data-slean="button" data-variant="link">Keys</a>
<select data-slean="select" data-size="sm" aria-label="Font size">
<option>12</option>
<option selected>14</option>
<option>16</option>
</select>
</div>Why this implementation exists
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
Usage
Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the
registration is injected for every static data-slean="toolbar"; without it, import
the register module once.
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/primitives/toolbar/register';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 aria-label or aria-labelledby. For a vertical toolbar set data-slean-orientation="vertical" and aria-orientation="vertical" together.
Controls need no marker: the toolbar uses <button>, <a href>, checkbox and button-like inputs and elements with tabindex. Content in a popover, a dialog, a hidden or an inert element inside the toolbar is not part of it, so a menu opened from a toolbar button keeps its own keys.
Following WAI-ARIA, keep text fields and selects few and at the end of the toolbar. 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. |
Runtime profile
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, in the runtime block, are the production registration with the core kernel.
Accessibility contract
role="toolbar"with a name;aria-orientation="vertical"for a vertical toolbar, which development validation checks againstdata-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
disabledandaria-disabledcontrols. 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.
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 |
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
Render the controls with or without the roving tabindex (see Tab order in the markup). No ids are generated, and the register module is safe to import on the server.
Before hydration
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.
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 | 0.25rem | gap, padding, margin-block, margin-inline |
--slean-border | var(--slean-neutral-6) | border, background |
--slean-radius-md | 0.625rem | border-radius |
--slean-surface | oklch(100% 0 0) | background |
--slean-fg | var(--slean-neutral-12) | color |
--slean-space-2 | 0.5rem | gap |
State selectors the stylesheet targets, all from the platform or ARIA: [aria-pressed="true"], [role="separator"].
Variant attributes: data-slean-orientation (vertical).
Compatibility notes
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.
Examples
Vertical
data-slean-orientation="vertical" with aria-orientation="vertical" moves between the controls with ArrowDown and ArrowUp. The disabled Eraser is skipped by the keys
and stays focusable, because it uses aria-disabled.
<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="sm" aria-pressed="true">Select</button>
<button type="button" data-slean="toggle" data-size="sm" aria-pressed="false">Pen</button>
<button type="button" data-slean="toggle" data-size="sm" aria-pressed="false" aria-disabled="true">
Eraser
</button>
<div role="separator"></div>
<button type="button" data-slean="button" data-variant="ghost" data-size="sm">Undo</button>
</div>Tab order in the markup
Both choices work with the behavior. Without tabindex, the page stays usable by
keyboard without JavaScript, and a Shift+Tab into a toolbar nobody has focused yet lands on its
last control. With an authored roving tabindex, the toolbar is one tab stop from
the first paint.
<!-- 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="Formatting">
<button type="button">Bold</button>
<button type="button">Italic</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="Formatting">
<button type="button" tabindex="0">Bold</button>
<button type="button" tabindex="-1">Italic</button>
</div>Testing
packages/primitives/tests/toolbar.test.tsVitest: which controls rove, arrow keys across a toggle group, separate controls, RTL, nested toolbars, 1000 toolbarsapps/playground/tests/primitives/toggles.spec.tsPlaywright: toggle, toggle group and toolbar with real focus and keys, and an axe scanpackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/toolbar/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/toolbar/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/internal/toolbar-controls.tswhich descendants are roving controls and which keep their keyspackages/primitives/src/toolbar/behavior.tsthe behavior definitionpackages/primitives/src/toolbar/register.tsthe registration modulepackages/styles/css/toolbar.cssthe optional stylesheet