Theme
Design your own
On this page (7)
Component Skill — components/toolbar/component-skill.md

Native basis

role="toolbar" container. Groups related controls (buttons, toggles, separators) into a single keyboard-navigable bar.

Web Platform APIs

role="toolbar"aria-orientationWAI-ARIA Toolbar Patternforced-colors

Classes

.toolbar.separator

Data attributes

• data-orientation - values: vertical

Notes

• Toolbars compose Toggle Groups, Button Groups, Buttons, and Separators.

• The toolbar handles roving tabindex - only one item is in the tab order at a time.

• Use vertical separators between logical groups of controls.

• The toolbar does not enforce selection logic - that's handled by the child components.

§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, and getState().config.rovingIndex reports 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:

StateTypeValuesDefaultDescription

§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.

StateDescription
default
The toolbar with one item in the tab order (roving tabindex).
Config fieldTypeDescription
focus?numberthe item to focus and put in the tab order, 0-based (default 0)
rovingIndex?numberreported by getState(): the index of the item in the tab order

Every element

MemberDescription
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.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?ToolbarStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

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 { name: ToolbarState; config: ToolbarStateConfigs[ToolbarState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state?{ name: ToolbarState; config: ToolbarStateConfigs[ToolbarState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

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

MemberDescription
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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?ToolbarStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

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.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: ToolbarState; config: ToolbarStateConfigs[ToolbarState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state{ name: ToolbarState; config: ToolbarStateConfigs[ToolbarState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.toolbarApi.store(el: HTMLElement): Store<{ name: ToolbarState; config: ToolbarStateConfigs[ToolbarState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: ToolbarState; config: ToolbarStateConfigs[ToolbarState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?ToolbarStateConfigs[S]its config
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