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.json2. Use it
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
/** @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
{% 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
{# 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
{{template "toolbar" (dict
"AriaLabel" "Text formatting"
"Body" (htmlSafe "…buttons / toggles / separators…"))}}View source
{{/*
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
<.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
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
<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
<!--
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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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>Further reading
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 [&_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>
<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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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 [&_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 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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>Further reading
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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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>Further reading
API Reference
<Toolbar>
| Prop | Type | Default | Description |
|---|---|---|---|
ariaControls | string | — | 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) |
ariaLabel | string | — | 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 |
ariaLabelledby | string | — | 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 |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |