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.
<script setup lang="ts">
import { BoldIcon, ItalicIcon, LinkIcon, UnderlineIcon } from "@lucide/vue"
import { Toggle, ToggleIcon } from "@ui-sdk/vue/toggle"
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@ui-sdk/vue/toolbar"
import { useLog } from "./log"
const styles = [
{ label: "Bold", icon: BoldIcon },
{ label: "Italic", icon: ItalicIcon },
{ label: "Underline", icon: UnderlineIcon },
]
const log = useLog()
</script>
<template>
<Toolbar aria-label="Formatting">
<Toggle
v-for="style in styles"
:key="style.label"
:aria-label="style.label"
@pressed-change="
(pressed) => log(`${style.label} ${pressed ? 'on' : 'off'}`)
"
>
<ToggleIcon>
<component :is="style.icon" />
</ToggleIcon>
</Toggle>
<ToolbarSeparator />
<ToolbarButton aria-label="Insert link" @click="log('Insert link')">
<LinkIcon aria-hidden="true" />
</ToolbarButton>
</Toolbar>
</template>Installation
npm install @ui-sdk/vue @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:
<script setup lang="ts">
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@ui-sdk/vue/toolbar"
</script>
<template>
<Toolbar aria-label="Formatting">
<ToolbarButton>Undo</ToolbarButton>
<ToolbarButton>Redo</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton>Insert Link</ToolbarButton>
</Toolbar>
</template>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.
<script setup lang="ts">
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
ListIcon,
ListOrderedIcon,
} from "@lucide/vue"
import { Toggle, ToggleGroup, ToggleIcon } from "@ui-sdk/vue/toggle"
import { Toolbar, ToolbarSeparator } from "@ui-sdk/vue/toolbar"
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 },
]
const log = useLog()
</script>
<template>
<Toolbar aria-label="Paragraph">
<ToggleGroup
:default-value="['left']"
:deselectable="false"
aria-label="Alignment"
@value-change="([value]) => log(`Align ${value}`)"
>
<Toggle
v-for="item in alignments"
:key="item.value"
:value="item.value"
:aria-label="item.label"
>
<ToggleIcon>
<component :is="item.icon" />
</ToggleIcon>
</Toggle>
</ToggleGroup>
<ToolbarSeparator />
<ToggleGroup
aria-label="List"
@value-change="
([value]) =>
log(lists.find((list) => list.value === value)?.label ?? 'No list')
"
>
<Toggle
v-for="item in lists"
:key="item.value"
:value="item.value"
:aria-label="item.label"
>
<ToggleIcon>
<component :is="item.icon" />
</ToggleIcon>
</Toggle>
</ToggleGroup>
</Toolbar>
</template>Groups
Use ToolbarGroup to name related controls. Set disabled on the group to disable them together; they stay focusable with aria-disabled.
<script setup lang="ts">
import {
ClipboardIcon,
CopyIcon,
RedoIcon,
ScissorsIcon,
UndoIcon,
} from "@lucide/vue"
import {
Toolbar,
ToolbarButton,
ToolbarGroup,
ToolbarSeparator,
} from "@ui-sdk/vue/toolbar"
import { useLog } from "./log"
const clipboard = [
{ label: "Cut", icon: ScissorsIcon },
{ label: "Copy", icon: CopyIcon },
{ label: "Paste", icon: ClipboardIcon },
]
const log = useLog()
</script>
<template>
<Toolbar aria-label="Edit">
<ToolbarGroup aria-label="Clipboard">
<ToolbarButton
v-for="item in clipboard"
:key="item.label"
:aria-label="item.label"
@click="log(item.label)"
>
<component :is="item.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>
</template>Menus
Render a Menu trigger through ToolbarButton's #render slot. In a horizontal toolbar, Down opens the menu while Left and Right keep moving between toolbar items.
<Menu>
<ToolbarButton>
<template #render="{ props }">
<MenuTrigger v-bind="props">Share</MenuTrigger>
</template>
</ToolbarButton>
<MenuContent>…</MenuContent>
</Menu><script setup lang="ts">
import { ChevronDownIcon } from "@lucide/vue"
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@ui-sdk/vue/menu"
import { Toolbar, ToolbarButton } from "@ui-sdk/vue/toolbar"
import { useLog } from "./log"
const log = useLog()
</script>
<template>
<Toolbar aria-label="Document">
<ToolbarButton @click="log('Saved')">Save</ToolbarButton>
<Menu @action="(value) => log(`Shared by ${value}`)">
<ToolbarButton>
<template #render="{ props }">
<MenuTrigger v-bind="props">
Share
<ChevronDownIcon aria-hidden="true" />
</MenuTrigger>
</template>
</ToolbarButton>
<MenuContent>
<MenuItem value="email">Email</MenuItem>
<MenuItem value="link">Copy Link</MenuItem>
</MenuContent>
</Menu>
<ToolbarButton @click="log('Printed')">Print</ToolbarButton>
</Toolbar>
</template>Selects
A Select trigger joins the same way. It keeps its select behavior while it takes part in the toolbar's focus order.
<script setup lang="ts">
import {
CheckIcon,
ChevronsUpDownIcon,
ZoomInIcon,
ZoomOutIcon,
} from "@lucide/vue"
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectTrigger,
SelectValue,
} from "@ui-sdk/vue/select"
import { Toolbar, ToolbarButton } from "@ui-sdk/vue/toolbar"
import { computed, ref } from "vue"
import { useLog } from "./log"
const zooms = ["50%", "75%", "100%", "150%", "200%"]
const log = useLog()
const zoom = ref("100%")
const index = computed(() => zooms.indexOf(zoom.value))
function change(value: string) {
zoom.value = value
log(`Zoom ${value}`)
}
</script>
<template>
<Toolbar aria-label="View">
<ToolbarButton
aria-label="Zoom out"
:disabled="index === 0"
@click="change(zooms[index - 1])"
>
<ZoomOutIcon aria-hidden="true" />
</ToolbarButton>
<Select :value="zoom" @value-change="(value) => value && change(value)">
<ToolbarButton aria-label="Zoom">
<template #render="{ props }">
<SelectTrigger v-bind="props">
<SelectValue />
<ChevronsUpDownIcon aria-hidden="true" />
</SelectTrigger>
</template>
</ToolbarButton>
<SelectContent>
<SelectItem v-for="zoom in zooms" :key="zoom" :value="zoom">
{{ zoom }}
<SelectItemIndicator>
<CheckIcon aria-hidden="true" />
</SelectItemIndicator>
</SelectItem>
</SelectContent>
</Select>
<ToolbarButton
aria-label="Zoom in"
:disabled="index === zooms.length - 1"
@click="change(zooms[index + 1])"
>
<ZoomInIcon aria-hidden="true" />
</ToolbarButton>
</Toolbar>
</template>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.
<script setup lang="ts">
import { MinusIcon, PlusIcon } from "@lucide/vue"
import {
NumberInput,
NumberInputControl,
NumberInputDecrement,
NumberInputGroup,
NumberInputIncrement,
} from "@ui-sdk/vue/number-input"
import { Toolbar, ToolbarInput, ToolbarSeparator } from "@ui-sdk/vue/toolbar"
</script>
<template>
<Toolbar aria-label="Text">
<NumberInput :default-value="14" :min="8" :max="72">
<NumberInputGroup>
<NumberInputDecrement aria-label="Decrease font size">
<MinusIcon aria-hidden="true" />
</NumberInputDecrement>
<ToolbarInput>
<template #render="{ props }">
<NumberInputControl v-bind="props" aria-label="Font size" />
</template>
</ToolbarInput>
<NumberInputIncrement aria-label="Increase font size">
<PlusIcon aria-hidden="true" />
</NumberInputIncrement>
</NumberInputGroup>
</NumberInput>
<ToolbarSeparator />
<ToolbarInput aria-label="Find" placeholder="Find…" />
</Toolbar>
</template>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.
<script setup lang="ts">
import { CheckIcon, MessageSquareIcon } from "@lucide/vue"
import {
Toolbar,
ToolbarButton,
ToolbarLink,
ToolbarSeparator,
} from "@ui-sdk/vue/toolbar"
import { useLog } from "./log"
const log = useLog()
function follow(event: MouseEvent) {
if (
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
event.preventDefault()
log(`Opened ${(event.currentTarget as HTMLAnchorElement).textContent}`)
}
</script>
<template>
<Toolbar aria-label="Pull request">
<ToolbarButton aria-label="Approve" @click="log('Approved')">
<CheckIcon aria-hidden="true" />
</ToolbarButton>
<ToolbarButton aria-label="Comment" @click="log('Comment')">
<MessageSquareIcon aria-hidden="true" />
</ToolbarButton>
<ToolbarSeparator />
<ToolbarLink href="#files" @click="follow">Files Changed</ToolbarLink>
<ToolbarLink href="#checks" @click="follow">Checks</ToolbarLink>
</Toolbar>
</template>Vertical
Set orientation="vertical" to move with Up and Down, like a canvas tool palette. Separators turn to run across the toolbar.
<script setup lang="ts">
import {
MousePointer2Icon,
PencilIcon,
SquareIcon,
Trash2Icon,
TypeIcon,
} from "@lucide/vue"
import { Toggle, ToggleGroup, ToggleIcon } from "@ui-sdk/vue/toggle"
import { Toolbar, ToolbarButton, ToolbarSeparator } from "@ui-sdk/vue/toolbar"
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 },
]
const log = useLog()
</script>
<template>
<Toolbar orientation="vertical" aria-label="Canvas">
<ToggleGroup
:default-value="['select']"
:deselectable="false"
orientation="vertical"
aria-label="Tool"
@value-change="
([value]) =>
log(`${tools.find((tool) => tool.value === value)?.label} tool`)
"
>
<Toggle
v-for="tool in tools"
:key="tool.value"
:value="tool.value"
:aria-label="tool.label"
>
<ToggleIcon>
<component :is="tool.icon" />
</ToggleIcon>
</Toggle>
</ToggleGroup>
<ToolbarSeparator />
<ToolbarButton aria-label="Delete" @click="log('Deleted selection')">
<Trash2Icon aria-hidden="true" />
</ToolbarButton>
</Toolbar>
</template>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 focusable-when-disabled to false to skip it instead.
<script setup lang="ts">
import { Toolbar, ToolbarButton } from "@ui-sdk/vue/toolbar"
import { useLog } from "./log"
const log = useLog()
</script>
<template>
<Toolbar aria-label="Message">
<ToolbarButton @click="log('Reply')">Reply</ToolbarButton>
<ToolbarButton disabled @click="log('Archive')">Archive</ToolbarButton>
<ToolbarButton
disabled
:focusable-when-disabled="false"
@click="log('Delete')"
>Delete</ToolbarButton
>
</Toolbar>
</template>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.
<script setup lang="ts">
import { Toolbar, ToolbarButton } from "@ui-sdk/vue/toolbar"
import { useLog } from "./log"
const log = useLog()
</script>
<template>
<Toolbar :loop="false" aria-label="Pages">
<ToolbarButton
v-for="label in ['First', 'Previous', 'Next', 'Last']"
:key="label"
@click="log(`${label} page`)"
>
{{ label }}
</ToolbarButton>
</Toolbar>
</template>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.
<script setup lang="ts">
import { BoldIcon, ItalicIcon, LinkIcon } from "@lucide/vue"
import { useShortcut } from "@ui-sdk/vue/shortcuts"
import {
createToolbarController,
Toolbar,
ToolbarButton,
} from "@ui-sdk/vue/toolbar"
import { useLog } from "./log"
const actions = [
{ label: "Bold", icon: BoldIcon },
{ label: "Italic", icon: ItalicIcon },
{ label: "Insert link", icon: LinkIcon },
]
const log = useLog()
const toolbar = createToolbarController()
useShortcut("alt+f10", () => toolbar.focus(), { allowInInput: true })
</script>
<template>
<div data-slot="stack">
<div data-slot="controls">
<button type="button" @click="toolbar.focus()">Focus Toolbar</button>
</div>
<Toolbar :controller="toolbar" aria-label="Notes formatting">
<ToolbarButton
v-for="action in actions"
:key="action.label"
:aria-label="action.label"
@click="log(action.label)"
>
<component :is="action.icon" aria-hidden="true" />
</ToolbarButton>
</Toolbar>
<textarea aria-label="Notes" placeholder="Type, then press Alt+F10" />
</div>
</template>Custom Rendering
Render a component through ToolbarButton or ToolbarInput to combine their behavior, as the menu, select and number input examples do. A #render slot must bind the toolbar props onto the inner component with v-bind. 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 slots receive on ToolbarButton and ToolbarInput, and the default slot on ToolbarButton:
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.