ToolbarORG
Groups related controls into a single keyboard-navigable bar. Composes Toggle Groups, Buttons, and Separators with roving tabindex navigation.
On this page (7)
§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 |
|---|
§API
Generated from toolbar.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type ToolbarState = 'default' - setState(name, config) takes the config of the state it names.
| State | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
default | The toolbar with one item in the tab order (roving tabindex).
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends ToolbarState>(name: S, config?: ToolbarStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: ToolbarState; config: ToolbarStateConfigs[ToolbarState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: ToolbarState; config: ToolbarStateConfigs[ToolbarState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: ToolbarState; config: ToolbarStateConfigs[ToolbarState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.toolbarApi.setState<S extends ToolbarState>(el: HTMLElement, name: S, config?: ToolbarStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.toolbarApi.getState(el: HTMLElement): { name: ToolbarState; config: ToolbarStateConfigs[ToolbarState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.toolbarApi.render(state: { name: ToolbarState; config: ToolbarStateConfigs[ToolbarState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.toolbarApi.store(el: HTMLElement): Store<{ name: ToolbarState; config: ToolbarStateConfigs[ToolbarState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.toolbarApi.commit<S extends ToolbarState>(el: HTMLElement, name: S, config?: ToolbarStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.toolbarStates: ToolbarState[] | The declared states, 'default' first: default. |
§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, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();const toolbarStates = ['default'];// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each// described, and every config field typed, described and named in the code./** setState() configs per state (getState() reports the roving focus). */export interface ToolbarStateConfigs { /** The toolbar with one item in the tab order (roving tabindex). */ default: { /** the item to focus and put in the tab order, 0-based (default 0) */ focus?: number; /** reported by getState(): the index of the item in the tab order */ rovingIndex?: number; };}/** * The markup of a state, for render(): the attributes a state writes, applied * to a detached copy of the authored markup ('default' IS the authored * markup). The live element gets the same markup from triggerStateChange - * the e2e render round trip proves they agree. */function applyMarkup(_el, _stateName) { // one state, and it writes no markup: the roving tabindex the runtime // keeps on the items is runtime-owned (see the e2e)}/** * 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 = componentState({ component: 'toolbar', states: toolbarStates, apply: (toolbar, state) => { const items = toolbarItems(toolbar); triggerStateChange(toolbar, items, state.name, state.config); }, read: (toolbar, state) => { 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: { ...state.config, rovingIndex: idx }, }; }, markup: (el, state) => applyMarkup(el, state.name),});df$.toolbarApi = toolbarApi;df$.toolbarStates = toolbarStates;const toolbarItems = (toolbar) => Array.from( dfDollar(toolbar).find('button:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])').toArray() );function init() { dfDollar('.toolbar[role="toolbar"]:not([data-init])').toArray().forEach((toolbar) => { toolbar.dataset.init = ''; // el.store + el.api (AGENTS.md "State through stores") bindComponent(toolbar, toolbarApi); 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 as HTMLElement); 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