Skip to content

Toolbar

A toolbar groups related controls into a band with one Tab stop: Arrow keys move focus between controls, Home/End jump to the ends. The markup is a container with role="toolbar" and an accessible name. With zero JavaScript every control is a native button, so the toolbar is just N Tab stops — enhanceToolbar upgrades it to the single-Tab-stop roving model.

import { enhanceToolbar } from "@relements/core/behaviors/toolbar";
const c = enhanceToolbar(document);
c.destroy(); // restores N native Tab stops

Group related controls with .re-toolbar__group (role="group") and divide them with a vertical .re-separator. A hosted menu uses the normal .re-menu markup — its trigger is a single toolbar item, and while the menu is open it governs its own keys.

Live example
<div class="re-toolbar" role="toolbar" aria-label="Text formatting" data-re-toolbar>
  <div class="re-toolbar__group" role="group" aria-label="Style">
    <button
      class="re-button"
      data-variant="ghost"
      data-size="sm"
      type="button"
      aria-pressed="true"
      aria-label="Bold"
    >
      B
    </button>
    <button
      class="re-button"
      data-variant="ghost"
      data-size="sm"
      type="button"
      aria-pressed="false"
      aria-label="Italic"
    >
      I
    </button>
    <button
      class="re-button"
      data-variant="ghost"
      data-size="sm"
      type="button"
      aria-pressed="false"
      aria-label="Underline"
    >
      U
    </button>
  </div>

  <div
    class="re-separator"
    role="separator"
    aria-orientation="vertical"
    data-orientation="vertical"
  ></div>

  <div class="re-toolbar__group" role="group" aria-label="Align">
    <button class="re-button" data-variant="ghost" data-size="sm" type="button">Left</button>
    <button
      class="re-button"
      data-variant="ghost"
      data-size="sm"
      type="button"
      aria-disabled="true"
    >
      Center
    </button>
  </div>

  <div
    class="re-separator"
    role="separator"
    aria-orientation="vertical"
    data-orientation="vertical"
  ></div>

  <div class="re-menu" data-re-menu>
    <button
      class="re-button"
      data-variant="ghost"
      data-size="sm"
      type="button"
      id="tb-more-btn"
      aria-haspopup="menu"
      aria-expanded="false"
      aria-controls="tb-more"
    >
      More
    </button>
    <div class="re-menu__panel" role="menu" id="tb-more" aria-labelledby="tb-more-btn" hidden>
      <button class="re-menu__item" role="menuitem" type="button">Clear formatting</button>
      <button class="re-menu__item" role="menuitem" type="button">Insert link…</button>
    </div>
  </div>
</div>

data-orientation="vertical" lays the band out as a column (Up/Down arrows roam) and sets aria-orientation="vertical".

Live example
<div
  class="re-toolbar"
  role="toolbar"
  aria-label="Drawing tools"
  data-orientation="vertical"
  data-re-toolbar
>
  <button
    class="re-button"
    data-variant="ghost"
    data-size="sm"
    type="button"
    aria-pressed="true"
  >
    Select
  </button>
  <button class="re-button" data-variant="ghost" data-size="sm" type="button">Pen</button>
  <button class="re-button" data-variant="ghost" data-size="sm" type="button">Erase</button>
</div>

data-wrap lets a long toolbar flow onto multiple rows; arrow navigation stays DOM-order linear and the single Tab stop is preserved.

Live example
<div
  class="re-toolbar"
  role="toolbar"
  aria-label="Many actions"
  data-wrap
  data-re-toolbar
  style="max-inline-size: 18rem"
>
  <button class="re-button" data-variant="ghost" data-size="sm" type="button">Cut</button>
  <button class="re-button" data-variant="ghost" data-size="sm" type="button">Copy</button>
  <button class="re-button" data-variant="ghost" data-size="sm" type="button">Paste</button>
  <button class="re-button" data-variant="ghost" data-size="sm" type="button">Undo</button>
  <button class="re-button" data-variant="ghost" data-size="sm" type="button">Redo</button>
  <button class="re-button" data-variant="ghost" data-size="sm" type="button">Delete</button>
</div>
  • Keyboard — One Tab stop enters/leaves the band; within it the arrow keys rove focus. Left/Right move (Up/Down when data-orientation="vertical"), and Home/End jump to the first/last control, clamped at the ends with no wrap. Horizontal arrows mirror in RTL (Right = previous, Left = next). Each control is a native <button>, so Enter/Space activates it. Native text, range and <select> controls keep their own Arrow/Home/End; a hosted .re-menu trigger keeps its own ArrowDown/ArrowUp to open, and once the menu is open it owns the keys. Without JavaScript every control is simply its own Tab stop — enhanceToolbar only collapses them to the single-Tab-stop roving model.
  • Focus — Each button shows the standard .re-button :focus-visible ring; the band’s padding clears the ring’s offset, and active/focused members are lifted (z-index) so the ring is never cramped at the edge or painted under a neighbor.
  • Semantics — A container with role="toolbar" and a required accessible name (aria-label); data-orientation="vertical" adds aria-orientation="vertical". Clusters use .re-toolbar__group (role="group" with its own aria-label), divided by a .re-separator (role="separator"). Toggle buttons carry aria-pressed; a hosted menu trigger uses aria-haspopup="menu" / aria-expanded.
  • Notes — Discoverable-but-disabled controls use aria-disabled="true" so they stay focusable in the roving order; native disabled controls are skipped. aria-disabled only suppresses pointer activation via CSS — a real <button> still fires on Enter/Space, so gate the action in your handler (if (el.getAttribute("aria-disabled") === "true") return). Under forced colors a toggled-on control is marked with system Highlight/HighlightText so the pressed state survives HCM. See the accessibility guide.
  • For attached, bordered button clusters use the Button group component.