Text formatting toolbar

Combines toggle groups with separators and a link button.

<div class="toolbar" role="toolbar" aria-label="Formatting">
  <div class="toggle-group" data-type="multiple" ...>
    <button class="toggle" aria-pressed="false">...</button>
    ...
  </div>
  <div class="separator" data-orientation="vertical" role="separator"></div>
  <div class="toggle-group" data-type="single" ...>...</div>
  <div class="separator" data-orientation="vertical" role="separator"></div>
  <button class="btn" data-variant="ghost">Link</button>
</div>

Simple toolbar

Quick actions with icon buttons.

<div class="toolbar" role="toolbar" aria-label="Actions">
  <button class="btn" data-variant="ghost" data-size="icon" aria-label="Undo">
    <i data-lucide="undo-2"></i>
  </button>
  <button class="btn" data-variant="ghost" data-size="icon" aria-label="Redo">
    <i data-lucide="redo-2"></i>
  </button>
  <div class="separator" data-orientation="vertical" role="separator"></div>
  <button class="btn" data-variant="ghost" data-size="icon" aria-label="Copy">...</button>
  <button class="btn" data-variant="ghost" data-size="icon" aria-label="Cut">...</button>
  <button class="btn" data-variant="ghost" data-size="icon" aria-label="Paste">...</button>
</div>

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;

    /* 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.

function init() {
  document.querySelectorAll('.toolbar[role="toolbar"]:not([data-init])').forEach((toolbar) => {
  toolbar.dataset.init = '';
  const items = Array.from(
    toolbar.querySelectorAll('button:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])')
  );
  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 });