A role="toolbar" container (WAI-ARIA APG Toolbar pattern) with one Tab stop and arrow keys between its controls. Its items are its focusable descendants, found in the DOM: a ToggleGroup, a Menu.Trigger, a Toggle, a plain <button>, a link or an input all join the sequence without a wrapper. Toolbar.Button is a command button with an action.
Styles: . Alignment: . Last command:
"use client";
import { startTransition, useState } from "react";
import { Menu } from "@funstack/ui/menu";
import { ToggleGroup } from "@funstack/ui/toggle-group";
import { Toolbar } from "@funstack/ui/toolbar";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { fakeSave } from "./fake-api.js";
// An editor toolbar: one Tab stop over two ToggleGroups, separators, a menu button, three
// Toolbar.Buttons (Copy is disabled but stays focusable) and a text input placed last. Left /
// Right walk every control; ArrowDown on Insert opens the menu.
export function ToolbarEditor() {
const [styles, setStyles] = useState<string[]>(["bold"]);
const [align, setAlign] = useState<string | null>("left");
const [log, setLog] = useState("Nothing yet.");
const note = (line: string) => startTransition(() => setLog(line));
return (
<DemoErrorBoundary>
<div className="demo-stack">
<Toolbar.Root aria-label="Formatting" className="demo-toolbar">
<ToggleGroup.Root
aria-label="Text style"
className="demo-toggle-group"
value={styles}
valueChangeAction={async (_next, { added, removed }) => {
await fakeSave("styles");
// Functional update: overlapping saves that settle out of order lose nothing.
startTransition(() =>
setStyles((prev) => [
...prev.filter((v) => !removed.includes(v)),
...added.filter((v) => !prev.includes(v)),
]),
);
}}
>
<ToggleGroup.Item value="bold" className="demo-toggle-item" aria-label="Bold">
<b aria-hidden>B</b>
</ToggleGroup.Item>
<ToggleGroup.Item value="italic" className="demo-toggle-item" aria-label="Italic">
<i aria-hidden>I</i>
</ToggleGroup.Item>
</ToggleGroup.Root>
<Toolbar.Separator className="demo-toolbar-separator" />
<ToggleGroup.Root
exclusive
aria-label="Text alignment"
className="demo-toggle-group"
value={align}
valueChangeAction={async (next) => {
await fakeSave(`alignment=${next}`);
startTransition(() => setAlign(next));
}}
>
<ToggleGroup.Item value="left" className="demo-toggle-item">
Left
</ToggleGroup.Item>
<ToggleGroup.Item value="center" className="demo-toggle-item">
Center
</ToggleGroup.Item>
</ToggleGroup.Root>
<Toolbar.Separator className="demo-toolbar-separator" />
<Menu.Root>
<Menu.Trigger className="demo-toolbar-button">Insert</Menu.Trigger>
<Menu.Popup className="demo-menu">
<Menu.Item action={() => note("Inserted an image.")}>Image</Menu.Item>
<Menu.Item action={() => note("Inserted a table.")}>Table</Menu.Item>
</Menu.Popup>
</Menu.Root>
<Toolbar.Button
className="demo-toolbar-button"
action={async () => {
await fakeSave("cut");
note("Cut.");
}}
>
{({ pending }) => (
<>
{pending && <span className="demo-spinner" aria-hidden />}
Cut
</>
)}
</Toolbar.Button>
<Toolbar.Button className="demo-toolbar-button" disabled>
Copy
</Toolbar.Button>
<Toolbar.Button className="demo-toolbar-button" action={() => note("Pasted.")}>
Paste
</Toolbar.Button>
{/* A text-entry control owns its arrow keys: at most one, and last (APG). */}
<input
type="text"
aria-label="Font size"
inputMode="numeric"
defaultValue="16"
size={3}
className="demo-toolbar-input"
/>
</Toolbar.Root>
<p>
Styles: <output>{styles.join(", ") || "none"}</output>. Alignment:{" "}
<output>{align ?? "none"}</output>. Last command: <output>{log}</output>
</p>
</div>
</DemoErrorBoundary>
);
}/* Toolbar: the toolbar is a flex row (a column when vertical); the separator is an <hr>. */
.demo-toolbar {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
border: 1px solid var(--color-border);
border-radius: 10px;
}
.demo-toolbar[data-orientation="vertical"] {
flex-direction: column;
align-items: stretch;
}
.demo-toolbar-button {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.3em 0.8em;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.demo-toolbar-button:hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
.demo-toolbar-button[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
.demo-toolbar-button[data-pending] {
cursor: progress;
}
.demo-toolbar-button:focus-visible,
.demo-toolbar-input:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.demo-toolbar-input {
padding: 0.25em 0.4em;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
font: inherit;
}
/* <hr> is a block; in a horizontal toolbar it is a vertical rule. */
.demo-toolbar-separator {
margin: 0;
border: 0;
background: var(--color-border);
}
.demo-toolbar-separator[data-orientation="vertical"] {
inline-size: 1px;
align-self: stretch;
}
.demo-toolbar-separator[data-orientation="horizontal"] {
block-size: 1px;
}Tab enters on the last focused control (Bold the first time). Left / Right walk every control in document order: both groups, the menu button, the three commands, the input. ArrowDown on Insert opens the menu. Copy is disabled but stays focusable, so it is announced. In the input, Left / Right move the caret and Tab leaves the toolbar (it is last).
Tool:
"use client";
import { useState } from "react";
import { Menu } from "@funstack/ui/menu";
import { Toolbar } from "@funstack/ui/toolbar";
// A vertical toolbar. Up / Down walk the tools; on Shapes, ArrowRight opens the menu with its
// first item focused (ArrowLeft under direction: rtl) and ArrowDown moves on to the next tool.
// The popup sits on the inline-end side; a right-to-left page would use side="left".
export function ToolbarVertical() {
const [tool, setTool] = useState("select");
return (
<div className="demo-row">
<Toolbar.Root orientation="vertical" aria-label="Tools" className="demo-toolbar">
<Toolbar.Button className="demo-toolbar-button" action={() => setTool("select")}>
Select
</Toolbar.Button>
<Toolbar.Button className="demo-toolbar-button" action={() => setTool("draw")}>
Draw
</Toolbar.Button>
<Toolbar.Separator className="demo-toolbar-separator" />
<Menu.Root>
<Menu.Trigger className="demo-toolbar-button">Shapes</Menu.Trigger>
<Menu.Popup className="demo-menu" side="right">
<Menu.Item action={() => setTool("rectangle")}>Rectangle</Menu.Item>
<Menu.Item action={() => setTool("ellipse")}>Ellipse</Menu.Item>
</Menu.Popup>
</Menu.Root>
</Toolbar.Root>
<p>
Tool: <output>{tool}</output>
</p>
</div>
);
}Up / Down walk the tools. A Menu.Trigger in a vertical toolbar opens on the inline-end arrow: ArrowRight, or ArrowLeft under direction: rtl. Put its popup on that side: side="right", or side="left" in a right-to-left page. ArrowDown moves on to the next tool.
import { Toolbar } from "@funstack/ui/toolbar";
<Toolbar.Root aria-label="Formatting">
<Toolbar.Button action={cut}>Cut</Toolbar.Button>
<Toolbar.Separator />
<ToggleGroup.Root aria-label="Text style">…</ToggleGroup.Root>
<Menu.Root>…</Menu.Root>
</Toolbar.Root>| Part | Element | Role |
|---|---|---|
Toolbar.Root | <div role="toolbar" aria-orientation> | Finds its items in the DOM and moves focus between them. |
Toolbar.Button | <button type="button"> | A command: action in its own transition, data-pending, focusable when disabled. |
Toolbar.Separator | <hr aria-orientation> | A divider, perpendicular to the toolbar. Not focusable. |
There are no Toolbar.Link, Toolbar.Group or Toolbar.ToggleGroup parts: put the element itself in the toolbar. Do not pass tabIndex to toolbar items; the toolbar writes tabindex itself. An element with its own tabIndex={-1} stays out of the sequence.
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Axis of the arrow keys; also aria-orientation. |
loop | boolean | true | Arrows wrap from the last item to the first. |
experimental_focusgroup | boolean | false | Renders focusgroup="toolbar inline|block wrap|nowrap"; where the browser implements it (Chromium 150+), the browser owns the Tab stop, the arrows, Home and End. See Experimental features. |
className | string | ((state: ToolbarRootState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ToolbarRootState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ToolbarRootState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: ToolbarRootState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
ToolbarRootState is { orientation }. There is no disabled: disable items individually, or wrap the toolbar in a <fieldset disabled>. role, aria-orientation, onKeyDown, onFocus, onBlur and onPointerDown are the library's and are not props.
| Prop | Type | Default | Description |
|---|---|---|---|
action | Action<[details: ToolbarButtonActionDetails]> | – | Runs inside the button's own transition on click, Enter or Space. Sync or async (a Server Function too); rejections reach the nearest Error Boundary above the button. |
disabled | boolean | false | Renders aria-disabled="true": the button stays focusable and in the arrow-key sequence (APG), and never dispatches. |
transition | ViewTransitionClass | – | Forwarded to the button's <ViewTransition default>: animates the commit that ends the action. |
className | string | ((state: ToolbarButtonState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ToolbarButtonState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ToolbarButtonState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: ToolbarButtonState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
ToolbarButtonState is { pending, disabled }. With render={<a href />} the link navigates before hydration and runs action after it; it gets no type.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: ToolbarSeparatorState) => string | undefined) | – | A class name, or a function of the separator's state. |
style | CSSProperties | ((state: ToolbarSeparatorState) => CSSProperties | undefined) | – | Inline style, or a function of the separator's state. |
render | ReactElement | ((props, state: ToolbarSeparatorState) => ReactNode) | – | Replace the default <hr> (element form) or take over rendering (function form). |
ToolbarSeparatorState is { orientation }: the separator's own orientation, "vertical" in a horizontal toolbar.
| Attribute | On | Meaning |
|---|---|---|
data-orientation | Root | The toolbar's orientation. |
data-orientation | Separator | The separator's own (perpendicular) orientation. |
data-pending | Button | Its action is in flight. |
data-disabled | Button | The disabled prop (with aria-disabled="true"). |
| Key | Horizontal | Vertical | Effect |
|---|---|---|---|
| ArrowRight / ArrowLeft | Next / previous item (swapped under direction: rtl) | Ignored | Moves focus; wraps if loop. |
| ArrowDown / ArrowUp | Ignored | Next / previous item | Same. |
| Home / End | First / last item | Same | Same. |
| Any of the above on a text-entry item | Not handled: the item keeps its caret. | ||
| Tab / Shift+Tab on a text-entry item | Next / previous toolbar item while one exists; otherwise leaves the toolbar. | ||
| Tab / Shift+Tab elsewhere | Leaves the toolbar; re-entry lands on the last focused item. | ||
Enter / Space on Toolbar.Button | Runs action, unless disabled. | ||
| A key the item handled itself | Ignored by the toolbar (a Menu.Trigger's ArrowDown opens its menu). |
Natively disabled controls are skipped; aria-disabled ones are reached. A popup rendered inside the toolbar (a Menu.Popup) is not part of the sequence: its items are the menu's.
The group joins the toolbar's single sequence: Left / Right (or Up / Down when vertical) flow through its Items like any other items, and the other axis does nothing on them. The group's own orientation, loop and experimental_focusgroup are ignored there (with a development warning); it still renders role="group", its label and its pending state.
| Context | Opens with the first item | Opens with the last item | Left to the Toolbar |
|---|---|---|---|
| Horizontal Toolbar | ArrowDown | ArrowUp | ArrowLeft / ArrowRight |
| Vertical Toolbar | ArrowRight (ArrowLeft under direction: rtl) | – | ArrowDown / ArrowUp |
The toolbar writes tabindex from an effect, so the server HTML has none: before hydration every control is its own Tab stop, reachable and working with its native behaviour. After hydration there is one Tab stop.
.toolbar { display: flex; gap: .25rem; }
.toolbar[data-orientation="vertical"] { flex-direction: column; }
/* <hr> is a block; in a horizontal toolbar it is a vertical rule. */
.toolbar-separator { margin: 0; border: 0; background: var(--line); }
.toolbar-separator[data-orientation="vertical"] { inline-size: 1px; align-self: stretch; }
.toolbar-separator[data-orientation="horizontal"] { block-size: 1px; }
.toolbar-button[aria-disabled="true"] { opacity: .5; }
.toolbar-button[data-pending] { cursor: progress; }
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}Toolbar.Button transition animates the commit that ends its action with a view transition; keep the reduced-motion block above in your stylesheet. The Toolbar adds no view transition types of its own.