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 lang="ts">
import { BoldIcon, ItalicIcon, LinkIcon, UnderlineIcon } from "@lucide/svelte"
import { Toggle, ToggleIcon } from "@ui-sdk/svelte/toggle"
import {
Toolbar,
ToolbarButton,
ToolbarSeparator,
} from "@ui-sdk/svelte/toolbar"
import { useLog } from "./log"
const styles = [
{ label: "Bold", icon: BoldIcon },
{ label: "Italic", icon: ItalicIcon },
{ label: "Underline", icon: UnderlineIcon },
]
const log = useLog()
</script>
<Toolbar aria-label="Formatting">
{#each styles as { label, icon: Icon } (label)}
<Toggle
aria-label={label}
onPressedChange={(pressed) => log(`${label} ${pressed ? "on" : "off"}`)}
>
<ToggleIcon>
<Icon />
</ToggleIcon>
</Toggle>
{/each}
<ToolbarSeparator />
<ToolbarButton aria-label="Insert link" onclick={() => log("Insert link")}>
<LinkIcon aria-hidden="true" />
</ToolbarButton>
</Toolbar>Installation
npm install @ui-sdk/svelte @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 lang="ts">
import {
Toolbar,
ToolbarButton,
ToolbarSeparator,
} from "@ui-sdk/svelte/toolbar"
</script>
<Toolbar aria-label="Formatting">
<ToolbarButton>Undo</ToolbarButton>
<ToolbarButton>Redo</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton>Insert Link</ToolbarButton>
</Toolbar>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 lang="ts">
import {
AlignCenterIcon,
AlignLeftIcon,
AlignRightIcon,
ListIcon,
ListOrderedIcon,
} from "@lucide/svelte"
import { Toggle, ToggleGroup, ToggleIcon } from "@ui-sdk/svelte/toggle"
import { Toolbar, ToolbarSeparator } from "@ui-sdk/svelte/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>
<Toolbar aria-label="Paragraph">
<ToggleGroup
defaultValue={["left"]}
deselectable={false}
aria-label="Alignment"
onValueChange={([value]) => log(`Align ${value}`)}
>
{#each alignments as { value, label, icon: Icon } (value)}
<Toggle {value} aria-label={label}>
<ToggleIcon>
<Icon />
</ToggleIcon>
</Toggle>
{/each}
</ToggleGroup>
<ToolbarSeparator />
<ToggleGroup
aria-label="List"
onValueChange={([value]) =>
log(lists.find((list) => list.value === value)?.label ?? "No list")}
>
{#each lists as { value, label, icon: Icon } (value)}
<Toggle {value} aria-label={label}>
<ToggleIcon>
<Icon />
</ToggleIcon>
</Toggle>
{/each}
</ToggleGroup>
</Toolbar>Groups
Use ToolbarGroup to name related controls. Set disabled on the group to disable them together; they stay focusable with aria-disabled.
<script lang="ts">
import {
ClipboardIcon,
CopyIcon,
RedoIcon,
ScissorsIcon,
UndoIcon,
} from "@lucide/svelte"
import {
Toolbar,
ToolbarButton,
ToolbarGroup,
ToolbarSeparator,
} from "@ui-sdk/svelte/toolbar"
import { useLog } from "./log"
const clipboard = [
{ label: "Cut", icon: ScissorsIcon },
{ label: "Copy", icon: CopyIcon },
{ label: "Paste", icon: ClipboardIcon },
]
const log = useLog()
</script>
<Toolbar aria-label="Edit">
<ToolbarGroup aria-label="Clipboard">
{#each clipboard as { label, icon: Icon } (label)}
<ToolbarButton aria-label={label} onclick={() => log(label)}>
<Icon aria-hidden="true" />
</ToolbarButton>
{/each}
</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>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>
{#snippet render({ props })}
<MenuTrigger {...props}>Share</MenuTrigger>
{/snippet}
</ToolbarButton>
<MenuContent>…</MenuContent>
</Menu><script lang="ts">
import { ChevronDownIcon } from "@lucide/svelte"
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@ui-sdk/svelte/menu"
import { Toolbar, ToolbarButton } from "@ui-sdk/svelte/toolbar"
import { useLog } from "./log"
const log = useLog()
</script>
<Toolbar aria-label="Document">
<ToolbarButton onclick={() => log("Saved")}>Save</ToolbarButton>
<Menu onAction={(value) => log(`Shared by ${value}`)}>
<ToolbarButton>
{#snippet render({ props })}
<MenuTrigger {...props}>
Share
<ChevronDownIcon aria-hidden="true" />
</MenuTrigger>
{/snippet}
</ToolbarButton>
<MenuContent>
<MenuItem value="email">Email</MenuItem>
<MenuItem value="link">Copy Link</MenuItem>
</MenuContent>
</Menu>
<ToolbarButton onclick={() => log("Printed")}>Print</ToolbarButton>
</Toolbar>Selects
A Select trigger joins the same way. It keeps its select behavior while it takes part in the toolbar's focus order.
<script lang="ts">
import {
CheckIcon,
ChevronsUpDownIcon,
ZoomInIcon,
ZoomOutIcon,
} from "@lucide/svelte"
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectTrigger,
SelectValue,
} from "@ui-sdk/svelte/select"
import { Toolbar, ToolbarButton } from "@ui-sdk/svelte/toolbar"
import { useLog } from "./log"
const zooms = ["50%", "75%", "100%", "150%", "200%"]
const log = useLog()
let zoom = $state("100%")
const index = $derived(zooms.indexOf(zoom))
function change(value: string) {
zoom = value
log(`Zoom ${value}`)
}
</script>
<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 aria-label="Zoom">
{#snippet render({ props })}
<SelectTrigger {...props}>
<SelectValue />
<ChevronsUpDownIcon aria-hidden="true" />
</SelectTrigger>
{/snippet}
</ToolbarButton>
<SelectContent>
{#each zooms as zoom (zoom)}
<SelectItem value={zoom}>
{zoom}
<SelectItemIndicator>
<CheckIcon aria-hidden="true" />
</SelectItemIndicator>
</SelectItem>
{/each}
</SelectContent>
</Select>
<ToolbarButton
aria-label="Zoom in"
disabled={index === zooms.length - 1}
onclick={() => change(zooms[index + 1])}
>
<ZoomInIcon aria-hidden="true" />
</ToolbarButton>
</Toolbar>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 lang="ts">
import { MinusIcon, PlusIcon } from "@lucide/svelte"
import {
NumberInput,
NumberInputControl,
NumberInputDecrement,
NumberInputGroup,
NumberInputIncrement,
} from "@ui-sdk/svelte/number-input"
import {
Toolbar,
ToolbarInput,
ToolbarSeparator,
} from "@ui-sdk/svelte/toolbar"
</script>
<Toolbar aria-label="Text">
<NumberInput defaultValue={14} min={8} max={72}>
<NumberInputGroup>
<NumberInputDecrement aria-label="Decrease font size">
<MinusIcon aria-hidden="true" />
</NumberInputDecrement>
<ToolbarInput aria-label="Font size">
{#snippet render({ props })}
<NumberInputControl {...props} />
{/snippet}
</ToolbarInput>
<NumberInputIncrement aria-label="Increase font size">
<PlusIcon aria-hidden="true" />
</NumberInputIncrement>
</NumberInputGroup>
</NumberInput>
<ToolbarSeparator />
<ToolbarInput aria-label="Find" placeholder="Find…" />
</Toolbar>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 lang="ts">
import { CheckIcon, MessageSquareIcon } from "@lucide/svelte"
import {
Toolbar,
ToolbarButton,
ToolbarLink,
ToolbarSeparator,
} from "@ui-sdk/svelte/toolbar"
import { useLog } from "./log"
const log = useLog()
function follow(event: MouseEvent & { currentTarget: HTMLAnchorElement }) {
if (
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
event.preventDefault()
log(`Opened ${event.currentTarget.textContent}`)
}
</script>
<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>Vertical
Set orientation="vertical" to move with Up and Down, like a canvas tool palette. Separators turn to run across the toolbar.
<script lang="ts">
import {
MousePointer2Icon,
PencilIcon,
SquareIcon,
Trash2Icon,
TypeIcon,
} from "@lucide/svelte"
import { Toggle, ToggleGroup, ToggleIcon } from "@ui-sdk/svelte/toggle"
import {
Toolbar,
ToolbarButton,
ToolbarSeparator,
} from "@ui-sdk/svelte/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>
<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`)}
>
{#each tools as { value, label, icon: Icon } (value)}
<Toggle {value} aria-label={label}>
<ToggleIcon>
<Icon />
</ToggleIcon>
</Toggle>
{/each}
</ToggleGroup>
<ToolbarSeparator />
<ToolbarButton aria-label="Delete" onclick={() => log("Deleted selection")}>
<Trash2Icon aria-hidden="true" />
</ToolbarButton>
</Toolbar>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.
<script lang="ts">
import { Toolbar, ToolbarButton } from "@ui-sdk/svelte/toolbar"
import { useLog } from "./log"
const log = useLog()
</script>
<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>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 lang="ts">
import { Toolbar, ToolbarButton } from "@ui-sdk/svelte/toolbar"
import { useLog } from "./log"
const log = useLog()
</script>
<Toolbar loop={false} aria-label="Pages">
{#each ["First", "Previous", "Next", "Last"] as label (label)}
<ToolbarButton onclick={() => log(`${label} page`)}>{label}</ToolbarButton>
{/each}
</Toolbar>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 lang="ts">
import { BoldIcon, ItalicIcon, LinkIcon } from "@lucide/svelte"
import { useShortcut } from "@ui-sdk/svelte/shortcuts"
import {
createToolbarController,
Toolbar,
ToolbarButton,
} from "@ui-sdk/svelte/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>
<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">
{#each actions as { label, icon: Icon } (label)}
<ToolbarButton aria-label={label} onclick={() => log(label)}>
<Icon aria-hidden="true" />
</ToolbarButton>
{/each}
</Toolbar>
<textarea aria-label="Notes" placeholder="Type, then press Alt+F10"
></textarea>
</div>Custom Rendering
Render a component through ToolbarButton or ToolbarInput with a render snippet to combine their behavior, as the menu, select and number input examples do. The snippet must spread its 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.