shshadcn-htmx

Components

Toolbar

A role="toolbar" container that groups related controls into a single tab stop. Arrow keys move a roving tabindex between buttons; Tab moves into and out of the whole group.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/toolbar.json

2. Use it

components/ui/toolbar.tsx
import {
  Toolbar, ToolbarButton, ToolbarToggle,
  ToolbarSeparator, ToolbarGroup,
} from "@/components/ui/toolbar"

<Toolbar ariaLabel="Text formatting">
  <ToolbarToggle pressed>Bold</ToolbarToggle>
  <ToolbarToggle>Italic</ToolbarToggle>
  <ToolbarSeparator />
  <ToolbarGroup ariaLabel="Alignment">
    <ToolbarButton>Left</ToolbarButton>
    <ToolbarButton>Center</ToolbarButton>
    <ToolbarButton>Right</ToolbarButton>
  </ToolbarGroup>
</Toolbar>
Or copy the source manually
components/ui/toolbar.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Toolbar — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn/ui ships a Toolbar built on Radix Toolbar. We don't copy that React
// code; we mirror its anatomy (Toolbar / Button / ToggleItem / Separator /
// Group) and translate to SSR + tiny-JS. Source of truth for the API shape:
//   repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/toolbar.tsx (anatomy only)
//
// Accessibility contract follows the WAI-ARIA APG toolbar pattern:
//   repos/aria-practices/content/patterns/toolbar/toolbar-pattern.html
//   repos/aria-practices/content/patterns/toolbar/examples/js/FormatToolbar.js
//     (the roving-tabindex implementation we model setFocusItem/Next/Prev on)
// Role + orientation semantics from MDN:
//   repos/mdn/files/en-us/web/accessibility/aria/reference/roles/toolbar_role/index.md
//
// The contract:
//   - The toolbar container has role="toolbar" and is a SINGLE tab stop.
//   - Exactly one control inside carries tabindex="0"; the rest tabindex="-1"
//     (a "roving tabindex"). ArrowLeft/ArrowRight (or Up/Down when vertical)
//     plus Home/End move that 0 between controls. See APG keyboard table.
//   - aria-orientation reflects the layout; default is horizontal (the role's
//     implicit value, set explicitly to be defensive for older AT).
//   - A separator inside a toolbar uses role="separator" with the orientation
//     PERPENDICULAR to the toolbar, is not focusable, and is skipped by the
//     arrow navigation.
//
// We render real <button> elements so Space/Enter activation, the button
// role, and disabled semantics come from the platform for free. An inline
// boot <script> sets the initial roving tabindex before paint (no flicker);
// public/site.js (keyed on data-slot="toolbar") owns the live keyboard
// contract, mirroring how Tabs is wired.
//
// Composition (matches shadcn's API):
//   <Toolbar ariaLabel="Text formatting">
//     <ToolbarToggle pressed>Bold</ToolbarToggle>
//     <ToolbarToggle>Italic</ToolbarToggle>
//     <ToolbarSeparator />
//     <ToolbarGroup ariaLabel="Alignment">
//       <ToolbarButton>Left</ToolbarButton>
//       <ToolbarButton>Center</ToolbarButton>
//     </ToolbarGroup>
//     <ToolbarSeparator />
//     <ToolbarButton asChild><a href="/docs">Docs</a></ToolbarButton>
//   </Toolbar>

import { cloneElement, isValidElement } from "hono/jsx"

export type ToolbarOrientation = "horizontal" | "vertical"

const containerBase =
  "group/toolbar flex w-fit items-center gap-1 rounded-md border bg-card p-1 shadow-xs " +
  "data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch"

// Toolbar controls share the ghost/sm Button look so a row of them reads as a
// cohesive set. Kept as a const so every control (button + toggle) is visually
// identical and only their state styling differs.
const itemBase =
  "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none " +
  "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 " +
  "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
  "disabled:pointer-events-none disabled:opacity-50 " +
  // aria-disabled mirrors the disabled affordance for cases where a control
  // must stay focusable (so AT lands on it and announces it's unavailable).
  // See repos/mdn/files/en-us/web/accessibility/aria/attributes/aria-disabled/.
  "aria-disabled:pointer-events-none aria-disabled:opacity-50 " +
  // Toggle state (aria-pressed) gets the accent fill, matching shadcn's Toggle.
  "aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 " +
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " +
  // htmx v4: mirror disabled affordance while a triggered request is in flight.
  "[&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70"

export function toolbarItemClasses(opts?: { class?: ClassValue }): string {
  return cn(itemBase, opts?.class)
}

type ToolbarProps = PropsWithChildren<{
  // Required when there's no visible label so AT can name the toolbar.
  // APG: a toolbar must be labelled via aria-label or aria-labelledby.
  ariaLabel?: string
  ariaLabelledby?: string
  // Id(s) of the element this toolbar operates on (e.g. a formatting toolbar
  // pointing at its editor textarea). The canonical APG example sets
  // aria-controls on the role="toolbar" element itself.
  // repos/aria-practices/content/patterns/toolbar/examples/toolbar.html
  ariaControls?: string
  orientation?: ToolbarOrientation
  class?: ClassValue
  id?: string
  // htmx and arbitrary attributes ride onto the toolbar container.
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function Toolbar(props: ToolbarProps) {
  const {
    ariaLabel,
    ariaLabelledby,
    ariaControls,
    orientation = "horizontal",
    class: className,
    children,
    ...rest
  } = props as any
  // Boot script: set the roving tabindex before paint so the toolbar is a
  // single tab stop immediately — the first non-disabled control gets
  // tabindex="0", every other focusable control gets tabindex="-1". This
  // runs synchronously after the element is parsed, so there's no flash of
  // all-tabbable controls before site.js loads. We model the "first
  // non-disabled control is focusable" rule on the APG example:
  //   repos/aria-practices/content/patterns/toolbar/examples/js/FormatToolbar.js
  const boot = `(function(el){
    var items = el.querySelectorAll('[data-toolbar-item]');
    var set = false;
    items.forEach(function(it){
      var off = it.hasAttribute('disabled') || it.getAttribute('aria-disabled') === 'true';
      if (!set && !off) { it.setAttribute('tabindex','0'); set = true; }
      else { it.setAttribute('tabindex','-1'); }
    });
    if (!set && items.length) items[0].setAttribute('tabindex','0');
    el.setAttribute('data-toolbar-ready','true');
  })(document.currentScript.previousElementSibling);`
  return (
    <>
      <div
        role="toolbar"
        data-slot="toolbar"
        data-orientation={orientation}
        // The role's implicit orientation is horizontal; we set it explicitly
        // so older AT reads it and so the value drives our arrow-key axis.
        aria-orientation={orientation}
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledby}
        aria-controls={ariaControls}
        class={cn(containerBase, className)}
        {...rest}
      >
        {children}
      </div>
      <script
        // biome-ignore lint/security/noDangerouslySetInnerHtml: SSR boot
        dangerouslySetInnerHTML={{ __html: boot }}
      />
    </>
  )
}

type ToolbarButtonProps = PropsWithChildren<{
  disabled?: boolean
  // Keep a disabled control focusable so AT announces it (APG note 2).
  ariaDisabled?: boolean
  ariaLabel?: string
  class?: ClassValue
  // Render as the single JSX child element (e.g. <a href>) with the toolbar
  // item classes merged on — SSR equivalent of shadcn/Radix `asChild`.
  asChild?: boolean
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function ToolbarButton(props: ToolbarButtonProps) {
  const {
    disabled,
    ariaDisabled,
    ariaLabel,
    class: className,
    asChild,
    children,
    ...rest
  } = props as any
  const classes = toolbarItemClasses({ class: className })
  // asChild path: clone the child (anchor, label, …) and brand it as a
  // toolbar item so the roving tabindex + arrow nav still find it. tabindex
  // is set by the boot script / site.js, not here.
  if (asChild && isValidElement(children)) {
    const child = children as any
    return cloneElement(child, {
      ...rest,
      class: cn(classes, child?.props?.class),
      "data-slot": "toolbar-button",
      "data-toolbar-item": "",
      "aria-label": ariaLabel,
      "aria-disabled": ariaDisabled ? "true" : undefined,
    })
  }
  return (
    <button
      type="button"
      data-slot="toolbar-button"
      data-toolbar-item=""
      disabled={disabled}
      aria-disabled={ariaDisabled ? "true" : undefined}
      aria-label={ariaLabel}
      class={classes}
      {...rest}
    >
      {children}
    </button>
  )
}

type ToolbarToggleProps = PropsWithChildren<{
  // APG/MDN toggle button: aria-pressed reflects the on/off state and the
  // label must stay constant across states.
  pressed?: boolean
  disabled?: boolean
  ariaDisabled?: boolean
  ariaLabel?: string
  class?: ClassValue
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function ToolbarToggle(props: ToolbarToggleProps) {
  const {
    pressed = false,
    disabled,
    ariaDisabled,
    ariaLabel,
    class: className,
    children,
    ...rest
  } = props as any
  return (
    <button
      type="button"
      data-slot="toolbar-toggle"
      data-toolbar-item=""
      aria-pressed={pressed ? "true" : "false"}
      disabled={disabled}
      aria-disabled={ariaDisabled ? "true" : undefined}
      aria-label={ariaLabel}
      class={toolbarItemClasses({ class: className })}
      {...rest}
    >
      {children}
    </button>
  )
}

type ToolbarSeparatorProps = {
  // The toolbar's orientation; the separator draws PERPENDICULAR to it.
  orientation?: ToolbarOrientation
  class?: ClassValue
}

export function ToolbarSeparator(props: ToolbarSeparatorProps) {
  const { orientation = "horizontal", class: className } = props
  // A separator inside a horizontal toolbar is a vertical rule, and vice
  // versa. role="separator" with aria-orientation set to the perpendicular
  // axis; NOT focusable, so the arrow navigation skips it (no
  // data-toolbar-item). See the toolbar_role MDN page + APG example markup.
  const sepOrientation = orientation === "horizontal" ? "vertical" : "horizontal"
  return (
    <div
      role="separator"
      data-slot="toolbar-separator"
      aria-orientation={sepOrientation}
      class={cn(
        "shrink-0 bg-border",
        sepOrientation === "vertical" ? "mx-0.5 h-5 w-px" : "my-0.5 h-px w-full",
        className,
      )}
    />
  )
}

type ToolbarGroupProps = PropsWithChildren<{
  // A visible label for the sub-group; rendered as aria-label on role="group".
  ariaLabel?: string
  class?: ClassValue
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function ToolbarGroup(props: ToolbarGroupProps) {
  const { ariaLabel, class: className, children, ...rest } = props as any
  // role="group" with a label lets AT announce the cluster (e.g. "Alignment")
  // without adding a tab stop — the controls inside still participate in the
  // toolbar's single roving tabindex.
  return (
    <div
      role="group"
      data-slot="toolbar-group"
      aria-label={ariaLabel}
      class={cn(
        "flex items-center gap-1 group-data-[orientation=vertical]/toolbar:flex-col group-data-[orientation=vertical]/toolbar:items-stretch",
        className,
      )}
      {...rest}
    >
      {children}
    </div>
  )
}

1. Save the file

Copy toolbar.html into templates/components/.

2. Use it

templates/components/toolbar.html
{% from "components/toolbar.html" import
     toolbar_open, toolbar_close, toolbar_button, toolbar_toggle,
     toolbar_separator, toolbar_group_open, toolbar_group_close %}

{{ toolbar_open(aria_label="Text formatting") }}
  {{ toolbar_toggle("Bold", pressed=true) }}
  {{ toolbar_toggle("Italic") }}
  {{ toolbar_separator() }}
  {{ toolbar_group_open(aria_label="Alignment") }}
    {{ toolbar_button("Left") }}
    {{ toolbar_button("Center") }}
  {{ toolbar_group_close() }}
{{ toolbar_close() }}
View source
templates/components/toolbar.html
{# Toolbar macros — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/toolbar.tsx. Renders role="toolbar" container + the
   button / toggle / separator / group controls. The boot <script> right
   after the container sets the roving tabindex (single tab stop) on first
   paint; public/site.js (keyed on data-slot="toolbar") owns the arrow-key
   contract. Accessibility contract:
     repos/aria-practices/content/patterns/toolbar/toolbar-pattern.html

   Usage:
     {% from "components/toolbar.html" import toolbar_open, toolbar_close,
          toolbar_button, toolbar_toggle, toolbar_separator,
          toolbar_group_open, toolbar_group_close %}

     {{ toolbar_open(aria_label="Text formatting") }}
       {{ toolbar_toggle("Bold",   pressed=true) }}
       {{ toolbar_toggle("Italic") }}
       {{ toolbar_separator() }}
       {{ toolbar_group_open(aria_label="Alignment") }}
         {{ toolbar_button("Left") }}
         {{ toolbar_button("Center") }}
       {{ toolbar_group_close() }}
     {{ toolbar_close() }} #}

{% macro toolbar_open(aria_label=none, aria_labelledby=none, aria_controls=none, orientation="horizontal", extra_class="", attrs={}) -%}
<div role="toolbar"
     data-slot="toolbar"
     data-orientation="{{ orientation }}"
     aria-orientation="{{ orientation }}"
     {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
     {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
     {# Id(s) of the element this toolbar operates on; APG sets aria-controls
        on the role="toolbar" element (.../patterns/toolbar/examples/toolbar.html). #}
     {%- if aria_controls %} aria-controls="{{ aria_controls }}"{% endif %}
     {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
     class="group/toolbar flex w-fit items-center gap-1 rounded-md border bg-card p-1 shadow-xs data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch {{ extra_class }}">
{%- endmacro %}

{% macro toolbar_close() -%}
</div>
<script>(function(el){
  var items = el.querySelectorAll('[data-toolbar-item]');
  var set = false;
  items.forEach(function(it){
    var off = it.hasAttribute('disabled') || it.getAttribute('aria-disabled') === 'true';
    if (!set && !off) { it.setAttribute('tabindex','0'); set = true; }
    else { it.setAttribute('tabindex','-1'); }
  });
  if (!set && items.length) items[0].setAttribute('tabindex','0');
  el.setAttribute('data-toolbar-ready','true');
})(document.currentScript.previousElementSibling);</script>
{%- endmacro %}

{% macro toolbar_button(label, disabled=false, aria_disabled=false, aria_label=none, extra_class="", attrs={}) -%}
<button type="button"
        data-slot="toolbar-button"
        data-toolbar-item=""
        {%- if disabled %} disabled{% endif %}
        {%- if aria_disabled %} aria-disabled="true"{% endif %}
        {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
        {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
        class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 {{ extra_class }}">{{ label }}</button>
{%- endmacro %}

{% macro toolbar_toggle(label, pressed=false, disabled=false, aria_disabled=false, aria_label=none, extra_class="", attrs={}) -%}
<button type="button"
        data-slot="toolbar-toggle"
        data-toolbar-item=""
        aria-pressed="{{ 'true' if pressed else 'false' }}"
        {%- if disabled %} disabled{% endif %}
        {%- if aria_disabled %} aria-disabled="true"{% endif %}
        {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
        {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
        class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 {{ extra_class }}">{{ label }}</button>
{%- endmacro %}

{# A separator inside a horizontal toolbar draws vertically (and vice versa);
   role="separator", not focusable, skipped by the arrow navigation. #}
{% macro toolbar_separator(orientation="horizontal", extra_class="") -%}
{% set sep = "vertical" if orientation == "horizontal" else "horizontal" -%}
<div role="separator"
     data-slot="toolbar-separator"
     aria-orientation="{{ sep }}"
     class="shrink-0 bg-border {{ 'mx-0.5 h-5 w-px' if sep == 'vertical' else 'my-0.5 h-px w-full' }} {{ extra_class }}"></div>
{%- endmacro %}

{% macro toolbar_group_open(aria_label=none, extra_class="") -%}
<div role="group"
     data-slot="toolbar-group"
     {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
     class="flex items-center gap-1 group-data-[orientation=vertical]/toolbar:flex-col group-data-[orientation=vertical]/toolbar:items-stretch {{ extra_class }}">
{%- endmacro %}
{% macro toolbar_group_close() %}</div>{% endmacro %}

1. Save the file

Add toolbar.tmpl alongside your other templates.

2. Use it

components/toolbar.tmpl
{{template "toolbar" (dict
  "AriaLabel" "Text formatting"
  "Body" (htmlSafe "…buttons / toggles / separators…"))}}
View source
components/toolbar.tmpl
{{/*
  Toolbar template — shadcn-htmx, htmx v4 + Tailwind v4.
  Mirrors registry/ui/toolbar.tsx. Named templates:
    - "toolbar"            — wrapper open + boot script (pass .Body HTML)
    - "toolbar_button"     — one <button> control
    - "toolbar_toggle"     — one aria-pressed toggle button
    - "toolbar_separator"  — a role="separator" rule (not focusable)
    - "toolbar_group"      — a labelled role="group" cluster (pass .Body)

  The boot script sets the roving tabindex (single tab stop) on first paint;
  public/site.js (keyed on data-slot="toolbar") owns the arrow-key contract.
  Accessibility contract:
    repos/aria-practices/content/patterns/toolbar/toolbar-pattern.html

  Hand-compose the inner HTML so you can interleave buttons / separators /
  groups, then pass it as .Body (template.HTML).
*/}}

{{define "toolbar"}}
{{- $orientation := or .Orientation "horizontal" -}}
<div role="toolbar"
     data-slot="toolbar"
     data-orientation="{{$orientation}}"
     aria-orientation="{{$orientation}}"
     {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
     {{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
     {{- /* Id(s) of the element this toolbar operates on; APG sets aria-controls on
            the role="toolbar" element (.../patterns/toolbar/examples/toolbar.html). */}}
     {{- if .AriaControls}} aria-controls="{{.AriaControls}}"{{end}}
     class="group/toolbar flex w-fit items-center gap-1 rounded-md border bg-card p-1 shadow-xs data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch">
  {{.Body}}
</div>
<script>(function(el){
  var items = el.querySelectorAll('[data-toolbar-item]');
  var set = false;
  items.forEach(function(it){
    var off = it.hasAttribute('disabled') || it.getAttribute('aria-disabled') === 'true';
    if (!set && !off) { it.setAttribute('tabindex','0'); set = true; }
    else { it.setAttribute('tabindex','-1'); }
  });
  if (!set && items.length) items[0].setAttribute('tabindex','0');
  el.setAttribute('data-toolbar-ready','true');
})(document.currentScript.previousElementSibling);</script>
{{end}}

{{define "toolbar_button"}}
<button type="button"
        data-slot="toolbar-button"
        data-toolbar-item=""
        {{- if .Disabled}} disabled{{end}}
        {{- if .AriaDisabled}} aria-disabled="true"{{end}}
        {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
        class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70">{{.Label}}</button>
{{end}}

{{define "toolbar_toggle"}}
<button type="button"
        data-slot="toolbar-toggle"
        data-toolbar-item=""
        aria-pressed="{{if .Pressed}}true{{else}}false{{end}}"
        {{- if .Disabled}} disabled{{end}}
        {{- if .AriaDisabled}} aria-disabled="true"{{end}}
        {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
        class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70">{{.Label}}</button>
{{end}}

{{define "toolbar_separator"}}
{{- $orientation := or .Orientation "horizontal" -}}
{{- $sep := "vertical" -}}{{- if eq $orientation "vertical" -}}{{- $sep = "horizontal" -}}{{- end -}}
<div role="separator"
     data-slot="toolbar-separator"
     aria-orientation="{{$sep}}"
     class="shrink-0 bg-border {{if eq $sep "vertical"}}mx-0.5 h-5 w-px{{else}}my-0.5 h-px w-full{{end}}"></div>
{{end}}

{{define "toolbar_group"}}
<div role="group"
     data-slot="toolbar-group"
     {{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
     class="flex items-center gap-1 group-data-[orientation=vertical]/toolbar:flex-col group-data-[orientation=vertical]/toolbar:items-stretch">
  {{.Body}}
</div>
{{end}}

1. Save the file

Drop toolbar.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/toolbar.ex
<.toolbar aria-label="Text formatting">
  <.toolbar_toggle pressed>Bold</.toolbar_toggle>
  <.toolbar_toggle>Italic</.toolbar_toggle>
  <.toolbar_separator />
  <.toolbar_group aria-label="Alignment">
    <.toolbar_button>Left</.toolbar_button>
    <.toolbar_button>Center</.toolbar_button>
  </.toolbar_group>
</.toolbar>
View source
lib/my_app_web/components/toolbar.ex
defmodule ShadcnHtmx.Components.Toolbar do
  @moduledoc """
  Toolbar — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Mirrors registry/ui/toolbar.tsx. Function components: `toolbar`,
  `toolbar_button`, `toolbar_toggle`, `toolbar_separator`, `toolbar_group`.

  The container has role="toolbar" and is a single tab stop; a boot <script>
  sets the roving tabindex on first paint, and public/site.js (keyed on
  data-slot="toolbar") owns the arrow-key contract. Accessibility contract:
  repos/aria-practices/content/patterns/toolbar/toolbar-pattern.html

  ## Examples

      <.toolbar aria-label="Text formatting">
        <.toolbar_toggle pressed>Bold</.toolbar_toggle>
        <.toolbar_toggle>Italic</.toolbar_toggle>
        <.toolbar_separator />
        <.toolbar_group aria-label="Alignment">
          <.toolbar_button>Left</.toolbar_button>
          <.toolbar_button>Center</.toolbar_button>
        </.toolbar_group>
      </.toolbar>
  """

  use Phoenix.Component

  attr :orientation, :string, default: "horizontal", values: ~w(horizontal vertical)
  attr :"aria-label", :string, default: nil
  attr :"aria-labelledby", :string, default: nil
  # Id(s) of the element this toolbar operates on; APG sets aria-controls on the
  # role="toolbar" element (.../patterns/toolbar/examples/toolbar.html).
  attr :"aria-controls", :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def toolbar(assigns) do
    ~H"""
    <div
      role="toolbar"
      data-slot="toolbar"
      data-orientation={@orientation}
      aria-orientation={@orientation}
      aria-label={assigns[:"aria-label"]}
      aria-labelledby={assigns[:"aria-labelledby"]}
      aria-controls={assigns[:"aria-controls"]}
      class={[
        "group/toolbar flex w-fit items-center gap-1 rounded-md border bg-card p-1 shadow-xs",
        "data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    <script>{Phoenix.HTML.raw(~s"""
      (function(el){
        var items = el.querySelectorAll('[data-toolbar-item]');
        var set = false;
        items.forEach(function(it){
          var off = it.hasAttribute('disabled') || it.getAttribute('aria-disabled') === 'true';
          if (!set && !off) { it.setAttribute('tabindex','0'); set = true; }
          else { it.setAttribute('tabindex','-1'); }
        });
        if (!set && items.length) items[0].setAttribute('tabindex','0');
        el.setAttribute('data-toolbar-ready','true');
      })(document.currentScript.previousElementSibling);
    """)}</script>
    """
  end

  attr :disabled, :boolean, default: false
  attr :"aria-disabled", :boolean, default: false
  attr :"aria-label", :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def toolbar_button(assigns) do
    ~H"""
    <button
      type="button"
      data-slot="toolbar-button"
      data-toolbar-item=""
      disabled={@disabled}
      aria-disabled={assigns[:"aria-disabled"] && "true"}
      aria-label={assigns[:"aria-label"]}
      class={[
        "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none",
        "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
        "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
        "disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
        "aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        "[&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </button>
    """
  end

  attr :pressed, :boolean, default: false
  attr :disabled, :boolean, default: false
  attr :"aria-disabled", :boolean, default: false
  attr :"aria-label", :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def toolbar_toggle(assigns) do
    ~H"""
    <button
      type="button"
      data-slot="toolbar-toggle"
      data-toolbar-item=""
      aria-pressed={if @pressed, do: "true", else: "false"}
      disabled={@disabled}
      aria-disabled={assigns[:"aria-disabled"] && "true"}
      aria-label={assigns[:"aria-label"]}
      class={[
        "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none",
        "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
        "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
        "disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50",
        "aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        "[&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </button>
    """
  end

  attr :orientation, :string, default: "horizontal", values: ~w(horizontal vertical)
  attr :class, :string, default: nil

  def toolbar_separator(assigns) do
    # A separator inside a horizontal toolbar draws vertically (and vice
    # versa); not focusable, so the arrow navigation skips it.
    sep = if assigns.orientation == "horizontal", do: "vertical", else: "horizontal"
    assigns = assign(assigns, :sep, sep)

    ~H"""
    <div
      role="separator"
      data-slot="toolbar-separator"
      aria-orientation={@sep}
      class={[
        "shrink-0 bg-border",
        if(@sep == "vertical", do: "mx-0.5 h-5 w-px", else: "my-0.5 h-px w-full"),
        @class
      ]}
    >
    </div>
    """
  end

  attr :"aria-label", :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block, required: true

  def toolbar_group(assigns) do
    ~H"""
    <div
      role="group"
      data-slot="toolbar-group"
      aria-label={assigns[:"aria-label"]}
      class={[
        "flex items-center gap-1 group-data-[orientation=vertical]/toolbar:flex-col group-data-[orientation=vertical]/toolbar:items-stretch",
        @class
      ]}
      {@rest}
    >
      {render_slot(@inner_block)}
    </div>
    """
  end
end

1. Save the file

Paste the markup; it relies only on the theme tokens in styles.css.

2. Use it

snippets/toolbar.html
<div role="toolbar" data-slot="toolbar"
     data-orientation="horizontal" aria-orientation="horizontal"
     aria-label="Text formatting" class="group/toolbar flex w-fit …">
  <button type="button" data-slot="toolbar-toggle" data-toolbar-item=""
          aria-pressed="true">Bold</button>

</div>
View source
snippets/toolbar.html
<!--
  shadcn-htmx — raw HTML toolbar snippet.

  Mirrors registry/ui/toolbar.tsx. The container has role="toolbar" and is a
  SINGLE tab stop: exactly one control carries tabindex="0", the rest
  tabindex="-1" (a roving tabindex). The inline <script> right after the
  toolbar sets that on first paint; the keyboard contract (ArrowLeft/Right or
  Up/Down + Home/End moving the 0 between controls) needs the wiring in
  public/site.js.

  Accessibility contract:
    repos/aria-practices/content/patterns/toolbar/toolbar-pattern.html

  Optional: a formatting/editor toolbar can name the element it operates on by
  adding aria-controls="<id>" to the role="toolbar" container, pointing at the
  editor/region it acts on (e.g. a textarea). The canonical APG example does
  exactly this — see
    repos/aria-practices/content/patterns/toolbar/examples/toolbar.html

  Required CSS theme variables: --card, --border, --foreground, --accent,
  --accent-foreground, --ring. See app/styles/input.css.

  Minimal inline JS for keyboard navigation (if you are not loading site.js):

    <script>
      document.addEventListener('keydown', function (e) {
        var item = e.target.closest('[data-toolbar-item]')
        if (!item) return
        var bar = item.closest('[data-slot="toolbar"]')
        if (!bar) return
        var vertical = bar.getAttribute('data-orientation') === 'vertical'
        var prev = vertical ? 'ArrowUp' : 'ArrowLeft'
        var next = vertical ? 'ArrowDown' : 'ArrowRight'
        if ([prev, next, 'Home', 'End'].indexOf(e.key) === -1) return
        e.preventDefault()
        var items = [].slice.call(
          bar.querySelectorAll('[data-toolbar-item]:not([disabled])')
        )
        var i = items.indexOf(item)
        var t =
          e.key === prev ? items[(i - 1 + items.length) % items.length] :
          e.key === next ? items[(i + 1) % items.length] :
          e.key === 'Home' ? items[0] : items[items.length - 1]
        items.forEach(function (x) { x.setAttribute('tabindex', '-1') })
        t.setAttribute('tabindex', '0')
        t.focus()
      })
    </script>
-->

<div role="toolbar"
     data-slot="toolbar"
     data-orientation="horizontal"
     aria-orientation="horizontal"
     aria-label="Text formatting"
     class="group/toolbar flex w-fit items-center gap-1 rounded-md border bg-card p-1 shadow-xs data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch">

  <button type="button" data-slot="toolbar-toggle" data-toolbar-item=""
          aria-pressed="true" aria-label="Bold"
          class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70">
    Bold
  </button>

  <button type="button" data-slot="toolbar-toggle" data-toolbar-item=""
          aria-pressed="false" aria-label="Italic"
          class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70">
    Italic
  </button>

  <div role="separator" data-slot="toolbar-separator" aria-orientation="vertical"
       class="shrink-0 bg-border mx-0.5 h-5 w-px"></div>

  <div role="group" data-slot="toolbar-group" aria-label="Alignment"
       class="flex items-center gap-1 group-data-[orientation=vertical]/toolbar:flex-col group-data-[orientation=vertical]/toolbar:items-stretch">

    <button type="button" data-slot="toolbar-button" data-toolbar-item=""
            class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70">
      Left
    </button>

    <button type="button" data-slot="toolbar-button" data-toolbar-item=""
            class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70">
      Center
    </button>

    <button type="button" data-slot="toolbar-button" data-toolbar-item=""
            class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70">
      Right
    </button>

  </div>

</div>

<script>
  (function (el) {
    var items = el.querySelectorAll('[data-toolbar-item]')
    var set = false
    items.forEach(function (it) {
      var off = it.hasAttribute('disabled') || it.getAttribute('aria-disabled') === 'true'
      if (!set && !off) { it.setAttribute('tabindex', '0'); set = true }
      else { it.setAttribute('tabindex', '-1') }
    })
    if (!set && items.length) items[0].setAttribute('tabindex', '0')
    el.setAttribute('data-toolbar-ready', 'true')
  })(document.currentScript.previousElementSibling)
</script>

Examples

Basic — a single tab stop

Three or more related buttons grouped under one role="toolbar". Tab lands once on the toolbar; ArrowLeft/ArrowRight (and Home/End) move focus between buttons.

The APG toolbar pattern reduces the number of tab stops: the whole group is one stop, and a roving tabindex decides which control inside is focusable. We render real <button> elements, so Space/Enter activation comes from the platform.

<Toolbar ariaLabel="History and view">
  <ToolbarButton>Undo</ToolbarButton>
  <ToolbarButton>Redo</ToolbarButton>
  <ToolbarSeparator />
  <ToolbarButton>Zoom in</ToolbarButton>
  <ToolbarButton>Zoom out</ToolbarButton>
</Toolbar>
{{ toolbar_open(aria_label="History and view") }}
  {{ toolbar_button("Undo") }}
  {{ toolbar_button("Redo") }}
  {{ toolbar_separator() }}
  {{ toolbar_button("Zoom in") }}
  {{ toolbar_button("Zoom out") }}
{{ toolbar_close() }}
{{template "toolbar" (dict "AriaLabel" "History and view" "Body" (htmlSafe "
  {{template \"toolbar_button\" (dict \"Label\" \"Undo\")}}
  {{template \"toolbar_button\" (dict \"Label\" \"Redo\")}}
  {{template \"toolbar_separator\" (dict)}}
  {{template \"toolbar_button\" (dict \"Label\" \"Zoom in\")}}
  {{template \"toolbar_button\" (dict \"Label\" \"Zoom out\")}}"))}}
<.toolbar aria-label="History and view">
  <.toolbar_button>Undo</.toolbar_button>
  <.toolbar_button>Redo</.toolbar_button>
  <.toolbar_separator />
  <.toolbar_button>Zoom in</.toolbar_button>
  <.toolbar_button>Zoom out</.toolbar_button>
</.toolbar>
<div role="toolbar" data-slot="toolbar" data-orientation="horizontal" aria-orientation="horizontal" aria-label="History and view" class="group/toolbar flex w-fit items-center gap-1 rounded-md border bg-card p-1 shadow-xs data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch">
  <button type="button" data-slot="toolbar-button" data-toolbar-item="" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Undo</button>
  <button type="button" data-slot="toolbar-button" data-toolbar-item="" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Redo</button>
  <div role="separator" data-slot="toolbar-separator" aria-orientation="vertical" class="shrink-0 bg-border mx-0.5 h-5 w-px">
  </div>
  <button type="button" data-slot="toolbar-button" data-toolbar-item="" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Zoom in</button>
  <button type="button" data-slot="toolbar-button" data-toolbar-item="" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Zoom out</button>
</div>
<script>
  (function(el){
    var items = el.querySelectorAll('[data-toolbar-item]');
    var set = false;
    items.forEach(function(it){
      var off = it.hasAttribute('disabled') || it.getAttribute('aria-disabled') === 'true';
      if (!set && !off) { it.setAttribute('tabindex','0'); set = true; }
      else { it.setAttribute('tabindex','-1'); }
    });
    if (!set && items.length) items[0].setAttribute('tabindex','0');
    el.setAttribute('data-toolbar-ready','true');
  })(document.currentScript.previousElementSibling);
</script>

Toggles, groups & separators

Toggle buttons carry aria-pressed; ToolbarGroup adds a labelled role="group" cluster (announced by AT) without adding a tab stop; separators mark visual divisions and are skipped by arrow nav.

A separator inside a horizontal toolbar draws as a vertical rule with role="separator" and an orientation perpendicular to the bar. It is not focusable, so the arrow navigation steps straight over it.

<Toolbar ariaLabel="Text formatting">
  <ToolbarToggle pressed ariaLabel="Bold">Bold</ToolbarToggle>
  <ToolbarToggle ariaLabel="Italic">Italic</ToolbarToggle>
  <ToolbarToggle ariaLabel="Underline">Underline</ToolbarToggle>
  <ToolbarSeparator />
  <ToolbarGroup ariaLabel="Alignment">
    <ToolbarButton>Left</ToolbarButton>
    <ToolbarButton>Center</ToolbarButton>
    <ToolbarButton>Right</ToolbarButton>
  </ToolbarGroup>
  <ToolbarSeparator />
  <ToolbarButton disabled>Clear</ToolbarButton>
</Toolbar>
{{ toolbar_open(aria_label="Text formatting") }}
  {{ toolbar_toggle("Bold", pressed=true, aria_label="Bold") }}
  {{ toolbar_toggle("Italic", aria_label="Italic") }}
  {{ toolbar_toggle("Underline", aria_label="Underline") }}
  {{ toolbar_separator() }}
  {{ toolbar_group_open(aria_label="Alignment") }}
    {{ toolbar_button("Left") }}
    {{ toolbar_button("Center") }}
    {{ toolbar_button("Right") }}
  {{ toolbar_group_close() }}
  {{ toolbar_separator() }}
  {{ toolbar_button("Clear", disabled=true) }}
{{ toolbar_close() }}
{{template "toolbar" (dict "AriaLabel" "Text formatting" "Body" (htmlSafe "
  {{template \"toolbar_toggle\" (dict \"Label\" \"Bold\" \"Pressed\" true)}}
  {{template \"toolbar_toggle\" (dict \"Label\" \"Italic\")}}
  {{template \"toolbar_separator\" (dict)}}
  {{template \"toolbar_group\" (dict \"AriaLabel\" \"Alignment\" \"Body\" (htmlSafe \"…\"))}}"))}}
<.toolbar aria-label="Text formatting">
  <.toolbar_toggle pressed aria-label="Bold">Bold</.toolbar_toggle>
  <.toolbar_toggle aria-label="Italic">Italic</.toolbar_toggle>
  <.toolbar_toggle aria-label="Underline">Underline</.toolbar_toggle>
  <.toolbar_separator />
  <.toolbar_group aria-label="Alignment">
    <.toolbar_button>Left</.toolbar_button>
    <.toolbar_button>Center</.toolbar_button>
    <.toolbar_button>Right</.toolbar_button>
  </.toolbar_group>
  <.toolbar_separator />
  <.toolbar_button disabled>Clear</.toolbar_button>
</.toolbar>
<div role="toolbar" data-slot="toolbar" data-orientation="horizontal" aria-orientation="horizontal" aria-label="Text formatting" class="group/toolbar flex w-fit items-center gap-1 rounded-md border bg-card p-1 shadow-xs data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch">
  <button type="button" data-slot="toolbar-toggle" data-toolbar-item="" aria-pressed="true" aria-label="Bold" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Bold</button>
  <button type="button" data-slot="toolbar-toggle" data-toolbar-item="" aria-pressed="false" aria-label="Italic" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Italic</button>
  <button type="button" data-slot="toolbar-toggle" data-toolbar-item="" aria-pressed="false" aria-label="Underline" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Underline</button>
  <div role="separator" data-slot="toolbar-separator" aria-orientation="vertical" class="shrink-0 bg-border mx-0.5 h-5 w-px">
  </div>
  <div role="group" data-slot="toolbar-group" aria-label="Alignment" class="flex items-center gap-1 group-data-[orientation=vertical]/toolbar:flex-col group-data-[orientation=vertical]/toolbar:items-stretch">
    <button type="button" data-slot="toolbar-button" data-toolbar-item="" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Left</button>
    <button type="button" data-slot="toolbar-button" data-toolbar-item="" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Center</button>
    <button type="button" data-slot="toolbar-button" data-toolbar-item="" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Right</button>
  </div>
  <div role="separator" data-slot="toolbar-separator" aria-orientation="vertical" class="shrink-0 bg-border mx-0.5 h-5 w-px">
  </div>
  <button type="button" data-slot="toolbar-button" data-toolbar-item="" disabled="" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Clear</button>
</div>
<script>
  (function(el){
    var items = el.querySelectorAll('[data-toolbar-item]');
    var set = false;
    items.forEach(function(it){
      var off = it.hasAttribute('disabled') || it.getAttribute('aria-disabled') === 'true';
      if (!set && !off) { it.setAttribute('tabindex','0'); set = true; }
      else { it.setAttribute('tabindex','-1'); }
    });
    if (!set && items.length) items[0].setAttribute('tabindex','0');
    el.setAttribute('data-toolbar-ready','true');
  })(document.currentScript.previousElementSibling);
</script>

Vertical orientation

Set orientation="vertical" and the bar stacks; aria-orientation flips so AT announces it, and Up/Down arrows drive navigation instead of Left/Right.

The toolbar role's implicit orientation is horizontal. When the controls are stacked we set aria-orientation="vertical", per the APG roles/states/properties section, and the arrow-key axis follows.

<Toolbar orientation="vertical" ariaLabel="Tools">
  <ToolbarButton>Move</ToolbarButton>
  <ToolbarButton>Draw</ToolbarButton>
  <ToolbarSeparator orientation="vertical" />
  <ToolbarButton>Erase</ToolbarButton>
  <ToolbarButton>Fill</ToolbarButton>
</Toolbar>
{{ toolbar_open(aria_label="Tools", orientation="vertical") }}
  {{ toolbar_button("Move") }}
  {{ toolbar_button("Draw") }}
  {{ toolbar_separator(orientation="vertical") }}
  {{ toolbar_button("Erase") }}
  {{ toolbar_button("Fill") }}
{{ toolbar_close() }}
{{template "toolbar" (dict "AriaLabel" "Tools" "Orientation" "vertical" "Body" (htmlSafe "
  {{template \"toolbar_button\" (dict \"Label\" \"Move\")}}
  {{template \"toolbar_separator\" (dict \"Orientation\" \"vertical\")}}
  {{template \"toolbar_button\" (dict \"Label\" \"Fill\")}}"))}}
<.toolbar orientation="vertical" aria-label="Tools">
  <.toolbar_button>Move</.toolbar_button>
  <.toolbar_button>Draw</.toolbar_button>
  <.toolbar_separator orientation="vertical" />
  <.toolbar_button>Erase</.toolbar_button>
  <.toolbar_button>Fill</.toolbar_button>
</.toolbar>
<div role="toolbar" data-slot="toolbar" data-orientation="vertical" aria-orientation="vertical" aria-label="Tools" class="group/toolbar flex w-fit items-center gap-1 rounded-md border bg-card p-1 shadow-xs data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch">
  <button type="button" data-slot="toolbar-button" data-toolbar-item="" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Move</button>
  <button type="button" data-slot="toolbar-button" data-toolbar-item="" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Draw</button>
  <div role="separator" data-slot="toolbar-separator" aria-orientation="horizontal" class="shrink-0 bg-border my-0.5 h-px w-full">
  </div>
  <button type="button" data-slot="toolbar-button" data-toolbar-item="" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Erase</button>
  <button type="button" data-slot="toolbar-button" data-toolbar-item="" class="inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-sm px-2.5 text-sm font-medium whitespace-nowrap text-foreground transition-all outline-none hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-accent aria-pressed:text-accent-foreground dark:aria-pressed:bg-accent/50 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70">Fill</button>
</div>
<script>
  (function(el){
    var items = el.querySelectorAll('[data-toolbar-item]');
    var set = false;
    items.forEach(function(it){
      var off = it.hasAttribute('disabled') || it.getAttribute('aria-disabled') === 'true';
      if (!set && !off) { it.setAttribute('tabindex','0'); set = true; }
      else { it.setAttribute('tabindex','-1'); }
    });
    if (!set && items.length) items[0].setAttribute('tabindex','0');
    el.setAttribute('data-toolbar-ready','true');
  })(document.currentScript.previousElementSibling);
</script>

API Reference

<Toolbar>

PropTypeDefaultDescription
ariaControlsstring
Id(s) of the element the toolbar operates on, set as aria-controls on the role="toolbar" container. Use for a formatting/editor toolbar to name the editor or region it acts on (e.g. a textarea), as in the canonical APG toolbar example.APGToolbar example (aria-controls on the toolbar)
ariaLabelstring
Accessible name for the toolbar when there's no visible label. APG requires every toolbar to be labelled via aria-label or aria-labelledby.APGToolbar roles, states & properties
ariaLabelledbystring
Id of a visible element that names the toolbar (alternative to ariaLabel).MDNaria-labelledby
orientation"horizontal"|"vertical""horizontal"
Layout axis. Sets aria-orientation and selects the arrow-key axis: Left/Right when horizontal, Up/Down when vertical. The role's implicit orientation is horizontal.MDNtoolbar role
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference