Give buttons, toggles, menus and fields one tab stop with arrow-key navigation.
Use a Toolbar to group the controls of an editor, a canvas or a table. Tab enters it once, and the arrow keys move between its buttons, toggles, menus and fields.
"use client"
import { Toggle, ToggleIcon } from "@ui-sdk/react/toggle"
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@ui-sdk/react/toolbar"
import { BoldIcon, ItalicIcon, LinkIcon, UnderlineIcon } from "lucide-react"
import { useLog } from "./log"
const styles = [
{ label: "Bold", icon: BoldIcon },
{ label: "Italic", icon: ItalicIcon },
{ label: "Underline", icon: UnderlineIcon },
]
function Example() {
const log = useLog()
return (
<Toolbar aria-label="Formatting">
{styles.map(({ label, icon: Icon }) => (
<Toggle
key={label}
aria-label={label}
onPressedChange={(pressed) =>
log(`${label} ${pressed ? "on" : "off"}`)
}
>
<ToggleIcon>
<Icon />
</ToggleIcon>
</Toggle>
))}
<ToolbarSeparator />
<ToolbarButton
aria-label="Insert link"
onClick={() => log("Insert link")}
>
<LinkIcon aria-hidden="true" />
</ToolbarButton>
</Toolbar>
)
}
export { Example }Installation
npm install @ui-sdk/react @ui-sdk/coreComposition
Use the following composition to build a Toolbar:
Toolbar
├── ToolbarButton
├── ToolbarSeparator
├── ToolbarGroup
│ ├── ToolbarButton
│ └── ToolbarButton
├── ToolbarLink
└── ToolbarInputPut buttons, links and fields straight inside the toolbar. ToolbarGroup names a set of related items, and ToolbarSeparator draws a line between sets. Toggles and toggle groups join the toolbar on their own, and menu and select triggers join through ToolbarButton.
Usage
Import the parts and put them together:
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@ui-sdk/react/toolbar"
function Example() {
return (
<Toolbar aria-label="Formatting">
<ToolbarButton>Undo</ToolbarButton>
<ToolbarButton>Redo</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton>Insert Link</ToolbarButton>
</Toolbar>
)
}
export { Example }Name the toolbar with aria-label, and give every icon-only control its own.
Toggle Groups
A Toggle group inside a toolbar joins its navigation instead of adding another tab stop. Arrow keys move focus without pressing anything; Enter or Space presses the focused toggle.
"use client"
import { Toggle, ToggleGroup, ToggleIcon } from "@ui-sdk/react/toggle"
import { Toolbar, ToolbarSeparator } from "@ui-sdk/react/toolbar"
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
ListIcon,
ListOrderedIcon,
} from "lucide-react"
import { useLog } from "./log"
const alignments = [
{ value: "left", label: "Align left", icon: AlignLeftIcon },
{ value: "center", label: "Align center", icon: AlignCenterIcon },
{ value: "right", label: "Align right", icon: AlignRightIcon },
]
const lists = [
{ value: "bulleted", label: "Bulleted list", icon: ListIcon },
{ value: "numbered", label: "Numbered list", icon: ListOrderedIcon },
]
function Example() {
const log = useLog()
return (
<Toolbar aria-label="Paragraph">
<ToggleGroup
defaultValue={["left"]}
deselectable={false}
aria-label="Alignment"
onValueChange={([value]) => log(`Align ${value}`)}
>
{alignments.map(({ value, label, icon: Icon }) => (
<Toggle key={value} value={value} aria-label={label}>
<ToggleIcon>
<Icon />
</ToggleIcon>
</Toggle>
))}
</ToggleGroup>
<ToolbarSeparator />
<ToggleGroup
aria-label="List"
onValueChange={([value]) =>
log(lists.find((list) => list.value === value)?.label ?? "No list")
}
>
{lists.map(({ value, label, icon: Icon }) => (
<Toggle key={value} value={value} aria-label={label}>
<ToggleIcon>
<Icon />
</ToggleIcon>
</Toggle>
))}
</ToggleGroup>
</Toolbar>
)
}
export { Example }Groups
Use ToolbarGroup to name related controls. Set disabled on the group to disable them together; they stay focusable with aria-disabled.
"use client"
import {
Toolbar,
ToolbarButton,
ToolbarGroup,
ToolbarSeparator,
} from "@ui-sdk/react/toolbar"
import {
ClipboardIcon,
CopyIcon,
RedoIcon,
ScissorsIcon,
UndoIcon,
} from "lucide-react"
import { useLog } from "./log"
const clipboard = [
{ label: "Cut", icon: ScissorsIcon },
{ label: "Copy", icon: CopyIcon },
{ label: "Paste", icon: ClipboardIcon },
]
function Example() {
const log = useLog()
return (
<Toolbar aria-label="Edit">
<ToolbarGroup aria-label="Clipboard">
{clipboard.map(({ label, icon: Icon }) => (
<ToolbarButton
key={label}
aria-label={label}
onClick={() => log(label)}
>
<Icon aria-hidden="true" />
</ToolbarButton>
))}
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup disabled aria-label="History">
<ToolbarButton aria-label="Undo">
<UndoIcon aria-hidden="true" />
</ToolbarButton>
<ToolbarButton aria-label="Redo">
<RedoIcon aria-hidden="true" />
</ToolbarButton>
</ToolbarGroup>
</Toolbar>
)
}
export { Example }Menus
Render a Menu trigger through ToolbarButton. In a horizontal toolbar, Down opens the menu while Left and Right keep moving between toolbar items.
<Menu>
<ToolbarButton render={<MenuTrigger />}>Share</ToolbarButton>
<MenuContent>…</MenuContent>
</Menu>"use client"
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@ui-sdk/react/menu"
import { Toolbar, ToolbarButton } from "@ui-sdk/react/toolbar"
import { ChevronDownIcon } from "lucide-react"
import { useLog } from "./log"
function Example() {
const log = useLog()
return (
<Toolbar aria-label="Document">
<ToolbarButton onClick={() => log("Saved")}>Save</ToolbarButton>
<Menu onAction={(value) => log(`Shared by ${value}`)}>
<ToolbarButton render={<MenuTrigger />}>
Share
<ChevronDownIcon aria-hidden="true" />
</ToolbarButton>
<MenuContent>
<MenuItem value="email">Email</MenuItem>
<MenuItem value="link">Copy Link</MenuItem>
</MenuContent>
</Menu>
<ToolbarButton onClick={() => log("Printed")}>Print</ToolbarButton>
</Toolbar>
)
}
export { Example }Selects
A Select trigger joins the same way. It keeps its select behavior while it takes part in the toolbar's focus order.
"use client"
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectTrigger,
SelectValue,
} from "@ui-sdk/react/select"
import { Toolbar, ToolbarButton } from "@ui-sdk/react/toolbar"
import {
CheckIcon,
ChevronsUpDownIcon,
ZoomInIcon,
ZoomOutIcon,
} from "lucide-react"
import * as React from "react"
import { useLog } from "./log"
const zooms = ["50%", "75%", "100%", "150%", "200%"]
function Example() {
const log = useLog()
const [zoom, setZoom] = React.useState("100%")
const index = zooms.indexOf(zoom)
const change = (value: string) => {
setZoom(value)
log(`Zoom ${value}`)
}
return (
<Toolbar aria-label="View">
<ToolbarButton
aria-label="Zoom out"
disabled={index === 0}
onClick={() => change(zooms[index - 1])}
>
<ZoomOutIcon aria-hidden="true" />
</ToolbarButton>
<Select value={zoom} onValueChange={(value) => value && change(value)}>
<ToolbarButton render={<SelectTrigger />} aria-label="Zoom">
<SelectValue />
<ChevronsUpDownIcon aria-hidden="true" />
</ToolbarButton>
<SelectContent>
{zooms.map((zoom) => (
<SelectItem key={zoom} value={zoom}>
{zoom}
<SelectItemIndicator>
<CheckIcon aria-hidden="true" />
</SelectItemIndicator>
</SelectItem>
))}
</SelectContent>
</Select>
<ToolbarButton
aria-label="Zoom in"
disabled={index === zooms.length - 1}
onClick={() => change(zooms[index + 1])}
>
<ZoomInIcon aria-hidden="true" />
</ToolbarButton>
</Toolbar>
)
}
export { Example }Fields
Use ToolbarInput for a text field, or render another field through it, like a Number Input control. A field keeps Home, End and the arrow keys while they move its caret. At the matching end, with no text selected, an arrow moves to the next item.
Entering a field with an arrow puts its caret at the far end in that direction: press the same arrow again to leave, or the other arrow to edit. A number input keeps Up and Down for stepping its value.
"use client"
import {
NumberInput,
NumberInputControl,
NumberInputDecrement,
NumberInputGroup,
NumberInputIncrement,
} from "@ui-sdk/react/number-input"
import { Toolbar, ToolbarInput, ToolbarSeparator } from "@ui-sdk/react/toolbar"
import { MinusIcon, PlusIcon } from "lucide-react"
function Example() {
return (
<Toolbar aria-label="Text">
<NumberInput defaultValue={14} min={8} max={72}>
<NumberInputGroup>
<NumberInputDecrement aria-label="Decrease font size">
<MinusIcon aria-hidden="true" />
</NumberInputDecrement>
<ToolbarInput
render={<NumberInputControl aria-label="Font size" />}
/>
<NumberInputIncrement aria-label="Increase font size">
<PlusIcon aria-hidden="true" />
</NumberInputIncrement>
</NumberInputGroup>
</NumberInput>
<ToolbarSeparator />
<ToolbarInput aria-label="Find" placeholder="Find…" />
</Toolbar>
)
}
export { Example }Links
ToolbarLink joins the same focus order and keeps its link behavior. The demo handles a plain click itself and leaves modified clicks, like opening in a new tab, to the browser.
"use client"
import {
Toolbar,
ToolbarButton,
ToolbarLink,
ToolbarSeparator,
} from "@ui-sdk/react/toolbar"
import { CheckIcon, MessageSquareIcon } from "lucide-react"
import type * as React from "react"
import { useLog } from "./log"
function Example() {
const log = useLog()
const follow = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
event.preventDefault()
log(`Opened ${event.currentTarget.textContent}`)
}
return (
<Toolbar aria-label="Pull request">
<ToolbarButton aria-label="Approve" onClick={() => log("Approved")}>
<CheckIcon aria-hidden="true" />
</ToolbarButton>
<ToolbarButton aria-label="Comment" onClick={() => log("Comment")}>
<MessageSquareIcon aria-hidden="true" />
</ToolbarButton>
<ToolbarSeparator />
<ToolbarLink href="#files" onClick={follow}>
Files Changed
</ToolbarLink>
<ToolbarLink href="#checks" onClick={follow}>
Checks
</ToolbarLink>
</Toolbar>
)
}
export { Example }Vertical
Set orientation="vertical" to move with Up and Down, like a canvas tool palette. Separators turn to run across the toolbar.
"use client"
import { Toggle, ToggleGroup, ToggleIcon } from "@ui-sdk/react/toggle"
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@ui-sdk/react/toolbar"
import {
MousePointer2Icon,
PencilIcon,
SquareIcon,
Trash2Icon,
TypeIcon,
} from "lucide-react"
import { useLog } from "./log"
const tools = [
{ value: "select", label: "Select", icon: MousePointer2Icon },
{ value: "draw", label: "Draw", icon: PencilIcon },
{ value: "shape", label: "Shape", icon: SquareIcon },
{ value: "text", label: "Text", icon: TypeIcon },
]
function Example() {
const log = useLog()
return (
<Toolbar orientation="vertical" aria-label="Canvas">
<ToggleGroup
defaultValue={["select"]}
deselectable={false}
orientation="vertical"
aria-label="Tool"
onValueChange={([value]) =>
log(`${tools.find((tool) => tool.value === value)?.label} tool`)
}
>
{tools.map(({ value, label, icon: Icon }) => (
<Toggle key={value} value={value} aria-label={label}>
<ToggleIcon>
<Icon />
</ToggleIcon>
</Toggle>
))}
</ToggleGroup>
<ToolbarSeparator />
<ToolbarButton
aria-label="Delete"
onClick={() => log("Deleted selection")}
>
<Trash2Icon aria-hidden="true" />
</ToolbarButton>
</Toolbar>
)
}
export { Example }Disabled
A disabled item stays reachable with aria-disabled, so people can find it, but it ignores presses, including keys meant for a component rendered through it. Set focusableWhenDisabled to false to skip it instead.
"use client"
import { Toolbar, ToolbarButton } from "@ui-sdk/react/toolbar"
import { useLog } from "./log"
function Example() {
const log = useLog()
return (
<Toolbar aria-label="Message">
<ToolbarButton onClick={() => log("Reply")}>Reply</ToolbarButton>
<ToolbarButton disabled onClick={() => log("Archive")}>
Archive
</ToolbarButton>
<ToolbarButton
disabled
focusableWhenDisabled={false}
onClick={() => log("Delete")}
>
Delete
</ToolbarButton>
</Toolbar>
)
}
export { Example }Set disabled on the toolbar to disable every item. The toolbar keeps its tab stop, and its fields can't be edited.
<Toolbar disabled aria-label="Formatting">
<ToolbarButton>Bold</ToolbarButton>
<ToolbarInput aria-label="Find" />
</Toolbar>Loop
Arrow keys wrap from the last item to the first by default. Set loop to false to stop at the ends.
"use client"
import { Toolbar, ToolbarButton } from "@ui-sdk/react/toolbar"
import { useLog } from "./log"
function Example() {
const log = useLog()
return (
<Toolbar loop={false} aria-label="Pages">
{["First", "Previous", "Next", "Last"].map((label) => (
<ToolbarButton key={label} onClick={() => log(`${label} page`)}>
{label}
</ToolbarButton>
))}
</Toolbar>
)
}
export { Example }Controller
Create a controller with createToolbarController and pass it as controller. Its focus() moves focus to the item focused last, like the Alt+F10 shortcut editors give their toolbars. Create one controller per toolbar.
"use client"
import { useShortcut } from "@ui-sdk/react/shortcuts"
import {
createToolbarController,
Toolbar,
ToolbarButton,
} from "@ui-sdk/react/toolbar"
import { BoldIcon, ItalicIcon, LinkIcon } from "lucide-react"
import * as React from "react"
import { useLog } from "./log"
const actions = [
{ label: "Bold", icon: BoldIcon },
{ label: "Italic", icon: ItalicIcon },
{ label: "Insert link", icon: LinkIcon },
]
function Example() {
const log = useLog()
const [toolbar] = React.useState(createToolbarController)
useShortcut("alt+f10", () => toolbar.focus(), { allowInInput: true })
return (
<div data-slot="stack">
<div data-slot="controls">
<button type="button" onClick={() => toolbar.focus()}>
Focus Toolbar
</button>
</div>
<Toolbar controller={toolbar} aria-label="Notes formatting">
{actions.map(({ label, icon: Icon }) => (
<ToolbarButton
key={label}
aria-label={label}
onClick={() => log(label)}
>
<Icon aria-hidden="true" />
</ToolbarButton>
))}
</Toolbar>
<textarea aria-label="Notes" placeholder="Type, then press Alt+F10" />
</div>
)
}
export { Example }Custom Rendering
Render a component through ToolbarButton or ToolbarInput to combine their behavior, as the menu, select and number input examples do. A render function must spread the toolbar props onto the inner component. Parts and Composition explains the rendering contract.
Styling
Use data-orientation on the root for a row or a column, and on a separator for the way its line runs, across the toolbar. Use data-disabled to style unavailable items and groups. Toggles inside keep their own data-pressed.
Each part has a data-slot, such as toolbar-button, and its state attributes are listed under Data Attributes.
Data Attributes
Toolbar
[data-slot="toolbar"]
| Attribute | What it means |
|---|---|
data-orientation | horizontalverticalThe toolbar's orientation. On a separator, which way the line runs: across the toolbar. |
data-disabled | Present when disabled, by the item itself, its group or the toolbar. |
ToolbarButton
[data-slot="toolbar-button"]
| Attribute | What it means |
|---|---|
data-disabled | Present when disabled, by the item itself, its group or the toolbar. |
data-orientation | horizontalverticalThe toolbar's orientation. On a separator, which way the line runs: across the toolbar. |
ToolbarLink
[data-slot="toolbar-link"]
| Attribute | What it means |
|---|---|
data-disabled | Present when disabled, by the item itself, its group or the toolbar. |
data-orientation | horizontalverticalThe toolbar's orientation. On a separator, which way the line runs: across the toolbar. |
ToolbarInput
[data-slot="toolbar-input"]
| Attribute | What it means |
|---|---|
data-disabled | Present when disabled, by the item itself, its group or the toolbar. |
data-orientation | horizontalverticalThe toolbar's orientation. On a separator, which way the line runs: across the toolbar. |
ToolbarGroup
[data-slot="toolbar-group"]
| Attribute | What it means |
|---|---|
data-disabled | Present when disabled, by the item itself, its group or the toolbar. |
data-orientation | horizontalverticalThe toolbar's orientation. On a separator, which way the line runs: across the toolbar. |
ToolbarSeparator
[data-slot="toolbar-separator"]
| Attribute | What it means |
|---|---|
data-orientation | horizontalverticalThe toolbar's orientation. On a separator, which way the line runs: across the toolbar. |
API Reference
Toolbar
orientation?ToolbarOrientation- Which arrow keys move between items: left and right, or up and down.
loop?boolean- Whether arrow keys wrap from the last item to the first.
disabled?boolean- Stops every item from being used. Items stay focusable with
aria-disabled, so the toolbar keeps its tab stop and stays discoverable. controller?{ focus: () => void; }- Focuses the toolbar from anywhere. Create one with
createToolbarController(). debug?boolean- Logs every state change with its reason to the console, in development builds.
ToolbarButton
disabled?boolean- Stops the item from being used. It stays focusable with
aria-disabled, so arrow keys still reach it and a tooltip can say why. focusableWhenDisabled?boolean- Whether a disabled item stays reachable with arrow keys. Turn it off to skip it, natively disabled.
ToolbarInput
disabled?boolean- Stops the item from being used. It stays focusable with
aria-disabled, so arrow keys still reach it and a tooltip can say why. focusableWhenDisabled?boolean- Whether a disabled item stays reachable with arrow keys. Turn it off to skip it, natively disabled.
ToolbarGroup
disabled?boolean- Disables every item in the group. They stay focusable with
aria-disabled.
ToolbarLink and ToolbarSeparator take no props of their own.
State
What render functions and state-based class names receive on ToolbarButton and ToolbarInput:
disabledboolean- Whether the item is disabled, by itself, its group or the toolbar.
orientationToolbarOrientation- The toolbar's
orientationoption:"vertical", or"horizontal"for anything else.
On ToolbarLink:
disabledboolean- Whether the link is disabled, by its group or the toolbar.
orientationToolbarOrientation- The toolbar's
orientationoption:"vertical", or"horizontal"for anything else.
On ToolbarSeparator:
orientationToolbarOrientation- Which way the line runs: across the toolbar, so vertical in a horizontal toolbar.
Controller
focus()() => void- Moves focus to the item that holds the toolbar's tab stop: the one focused last, or the first.
Accessibility
The toolbar follows the WAI-ARIA toolbar pattern. The root has the toolbar role and aria-orientation, and separators have the separator role.
Keyboard
The toolbar has one tab stop, a roving focus. Tab enters on the first enabled item, then returns to the item focused last. The next Tab leaves the toolbar.
| Key | Action |
|---|---|
ArrowRight / ArrowLeft | Move focus in a horizontal toolbar |
ArrowDown / ArrowUp | Move focus in a vertical toolbar |
Home / End | Focus the first or last item, except in a field |
Enter / Space | Activate a button or toggle, or open its popup |
Controls keep the keys they use: Down opens a menu, and Up and Down step a number input. Moving focus never presses a toggle. In right-to-left text, Left and Right swap.
Tested Behavior
Every line below is an automated test.
- The toolbar is named and has a horizontal orientation.
- Tab lands on the first item, and Tab again leaves the toolbar.
- Shift+Tab from after the toolbar lands on its tab stop.
- ArrowRight moves through every item, into toggle groups and out of fields.
- ArrowRight on the last item wraps to the first, and ArrowLeft back.
- Arrow keys never press a toggle, and Space and Enter do.
- Tab comes back to the item focused last.
- A toggle group in the toolbar is no tab stop of its own.
- A disabled button stays reachable, says it's disabled and does nothing.
- A button runs its action when pressed.
- A menu trigger rendered through a button opens with ArrowDown, and arrows still move past it.
- Choosing from a menu in the toolbar returns focus to its trigger.
- A text field keeps Home and End for its caret.
- Up and Down step a number field in the toolbar.
- Separators run across the toolbar.
- A group of buttons is named.
- A link in the toolbar follows its href with Enter.
- With loop off, arrows stop at the ends.
- A disabled toolbar keeps one tab stop, and every item says it's disabled.
- ArrowDown and ArrowUp move between items, and Left and Right don't.
- Separators run across a vertical toolbar.
- Toggles and toggle groups in a disabled group are disabled.
- A link in a disabled group is disabled, and follows its href once enabled.
- Enabling a group enables the toggles and buttons in it.
Runs in React, Vue and web components.