Theme
On this page (6)
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

§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