ToolbarORG
Groups related controls into a single keyboard-navigable bar. Composes Toggle Groups, Buttons, and Separators with roving tabindex navigation.
On this page (6)
§Text formatting toolbar
Combines toggle groups with separators and a link button.
§Simple toolbar
Quick actions with icon buttons.
§Sizes
Set data-size on the .toolbar root and the chrome plus its .btn / .toggle controls scale together - the ladder mirrors the button sizes (md matches the unsized default).
§States
Named states via the shared State API, driven per instance through the bound api:
default- roving tabindex at the authored position (first item);setState('default', { focus: n })parks the roving stop on item n instead, andgetState().config.rovingIndexreports where it currently is
The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/toolbar-{state}.png.
Machine contract - verified against toolbar.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|
§CSS view file
/* -- Toolbar component ------------------------------------------ */@layer components { .toolbar { display: flex; align-items: center; gap: 0.25rem; padding: 0.25rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--background); width: fit-content; /* -- Sizes ---------------------------------------------------- data-size on the .toolbar root scales the chrome (padding/gap) and the direct .btn/.toggle/.toggle-group controls together, mirroring the button ladder (md = .btn's default 2.25rem). The descendant selector (0-3-0) outranks each control's own [data-size] rule (0-2-0). */ &[data-size="xs"] { gap: 0.125rem; padding: 0.125rem; & :is(.btn, .toggle) { height: 1.75rem; padding: 0 0.5rem; font-size: 0.75rem; } } &[data-size="sm"] { gap: 0.1875rem; padding: 0.1875rem; & :is(.btn, .toggle) { height: 2rem; padding: 0 0.75rem; font-size: 0.8125rem; } } &[data-size="md"] { gap: 0.25rem; padding: 0.25rem; & :is(.btn, .toggle) { height: 2.25rem; } } &[data-size="lg"] { gap: 0.375rem; padding: 0.375rem; & :is(.btn, .toggle) { height: 2.75rem; padding: 0 1rem; font-size: 1rem; } } &[data-size="xl"] { gap: 0.5rem; padding: 0.5rem; & :is(.btn, .toggle) { height: 3.25rem; padding: 0 1.25rem; font-size: 1.125rem; } } /* Vertical separators stretch to fill toolbar height */ & > .separator[data-orientation="vertical"] { align-self: stretch; height: auto; margin: 0.25rem 0.25rem; } /* Vertical toolbar */ &[aria-orientation="vertical"] { flex-direction: column; & > .separator[data-orientation="horizontal"], & > .separator:not([data-orientation]) { height: 1px; width: 1.5rem; margin: 0.25rem 0; } } } @media (forced-colors: active) { .toolbar { border-color: ButtonText; } }}§JavaScript view file
Roving tabindex for role="toolbar" containers. Arrow keys move focus between focusable children.
// -- Toolbar --------------------------------------------------// Roving tabindex for role="toolbar" containers.// Arrow keys move focus between focusable children, plus the named-state API// so agents/tests can reset the roving position by name (AGENTS.md// "State API"). The toolbar's only observable state is *which item holds the// roving tabindex*, so 'default' means "back to the authored position" and// getState() reports where the roving stop currently is.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();const toolbarStates = ['default'];/** * UI side of setState: 'default' restores the roving tabindex to the first * enabled item (the authored position); optional { focus: n } config parks * the roving stop on the nth item instead - focus is only moved there if the * toolbar already contains the focus, matching native roving semantics. */function triggerStateChange(toolbar, items, stateName, config) { if (stateName !== 'default' || items.length === 0) return; const target = items[Math.min(Number(config?.focus ?? 0), items.length - 1)] || items[0]; items.forEach((item) => item.setAttribute('tabindex', item === target ? '0' : '-1')); if (toolbar.contains(document.activeElement)) target.focus();}/** Registry-level API; pass the toolbar element explicitly. Unknown names throw. */export const toolbarApi = { setState(toolbar, stateName, config = {}) { if (!toolbarStates.includes(stateName)) { throw new Error(`toolbar: unknown state "${stateName}" (supported: ${toolbarStates.join(', ')})`); } const items = toolbarItems(toolbar); triggerStateChange(toolbar, items, stateName, config); // state lives on the ELEMENT, not the module (many toolbars per page) toolbar.dataset.stateName = stateName; toolbar._stateConfig = config; }, getState(toolbar) { const items = toolbarItems(toolbar); const idx = items.findIndex((item) => item.getAttribute('tabindex') === '0'); return { name: toolbar.dataset.stateName || 'default', // observable roving position - reflects arrow-key movement too config: { ...toolbar._stateConfig, rovingIndex: idx }, }; },};df$.toolbarApi = toolbarApi;df$.toolbarStates = toolbarStates;const toolbarItems = (toolbar) => Array.from( toolbar.querySelectorAll('button:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])') );function init() { document.querySelectorAll('.toolbar[role="toolbar"]:not([data-init])').forEach((toolbar) => { toolbar.dataset.init = ''; // bind-scope the api per instance: `$('#fmt').api.setState('default')` toolbar.api = { setState: (stateName, config) => toolbarApi.setState(toolbar, stateName, config), getState: () => toolbarApi.getState(toolbar), }; const items = toolbarItems(toolbar); if (items.length === 0) return; items.forEach((item, i) => { item.setAttribute('tabindex', i === 0 ? '0' : '-1'); }); toolbar.addEventListener('keydown', (e) => { const current = items.indexOf(document.activeElement); if (current === -1) return; const vertical = toolbar.getAttribute('aria-orientation') === 'vertical'; const fwd = vertical ? 'ArrowDown' : 'ArrowRight'; const bwd = vertical ? 'ArrowUp' : 'ArrowLeft'; let next; if (e.key === fwd) { e.preventDefault(); next = (current + 1) % items.length; } else if (e.key === bwd) { e.preventDefault(); next = (current - 1 + items.length) % items.length; } else if (e.key === 'Home') { e.preventDefault(); next = 0; } else if (e.key === 'End') { e.preventDefault(); next = items.length - 1; } if (next !== undefined) { items[current].setAttribute('tabindex', '-1'); items[next].setAttribute('tabindex', '0'); items[next].focus(); } });});}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub