Skip to content

Toolbar

A semantic, roving-focus strip of related controls — one tab stop, arrow-key navigation, and measured overflow that collapses out-of-space actions into a menu.

Stablev0.4.2added in v0.2.0@garn/ui/toolbar
On this page

Default

A text-formatting bar: history buttons, a multi-select style group, and a single-select alignment group on one tab stop.

Appearance

Appearances

solid / soft / outline / ghost surfaces of the same bar.

Sizes

The sm / md / lg density rungs.

Overflow

Overflow

Measured collapse: a width-constrained bar folds out-of-space actions into a '…' menu, none dropped.

Vertical Overflow

A height-bounded vertical rail collapsing into a side-anchored more-menu with a custom overflowIcon.

Split Button

Split Button

A ToolbarSplitButton: primary Save plus a menu of related options, as one roving stop.

Floating

Floating

ToolbarFloating anchored to a text selection, keeping focus on the caret.

Select a phrase to reveal a floating formatting bar. Here the bubble is anchored above this highlighted selection so it tracks the text it acts on.

Vertical

A vertical tool rail — ↑/↓ navigation and aria-orientation=vertical.

Actions

An app action bar mixing a group, text buttons with shortcuts, a disabled button, and a link.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add toolbar
First time? Set up garn in your project
Registry
toolbar
Deps
@radix-ui/react-toolbarclass-variance-authoritylucide-react
Registry deps
utilsfield-variantsuse-resize-observeruse-isomorphic-layout-effectuse-merged-refbuttonbutton-groupkbdpopovertoggletoggle-groupseparatortooltipdropdown-menu

Import

import { ToolbarGroup, ToolbarSeparator, ToolbarButton, ToolbarSplitButton, ToolbarToggleGroup, ToolbarToggleItem, ToolbarLink, ToolbarInput, ToolbarFloating } from "@garn/ui/toolbar";

Anatomy

The parts this component is built from — compose them to assemble it.

ToolbarGroup[data-slot="toolbar-group"]

A focus-transparent role=group cluster of related controls. It adds no tab stop of its own — its children are roving items of the outer toolbar — and collapses as a unit into the overflow menu (its aria-label becomes a menu label).

ToolbarSeparator[data-slot="toolbar-separator"]

A decorative divider between control clusters, rotated with orientation. Suppressed automatically when a collapse leaves it leading or trailing, and de-duplicated inside the overflow menu (where it renders as a DropdownMenuSeparator).

ToolbarButton[data-slot="toolbar-button"]

An action button (composes Button, default variant ghost). Takes tooltip (visible name for an icon-only button) and shortcut (one descriptor rendered as a <kbd> in the tooltip, a shortcut hint in the overflow menu, and aria-keyshortcuts when a string). Focusable-when-disabled: a disabled button stays arrow-reachable via aria-disabled, inert to activation.

ToolbarSplitButton[data-slot="toolbar-split-button"]

A primary action attached to a menu of related ones, as ONE roving stop — the toolbar-native split button (the standalone SplitButton cannot be a toolbar item). Prop-driven: label / onSelect / menuLabel, with the menu contents as children. The menu half is tabIndex=-1 and reachable on the perpendicular arrow axis; the pair counts once toward the APG threshold and collapses to a primary row + a named submenu.

ToolbarToggleGroup[data-slot="toolbar-toggle-group"]

A single- or multiple-selection toggle set that delegates roving to the toolbar (Radix ToggleGroup with rovingFocus off), so it introduces no second tab stop. Uncontrolled selection parks on a root-lifted store keyed per group, so it survives being relocated between the bar and the overflow menu.

ToolbarToggleItem[data-slot="toolbar-toggle-item"]

One toggle within a ToolbarToggleGroup (styled by toggleVariants). Takes tooltip for an icon-only visible name. In the overflow menu it renders as a DropdownMenuCheckboxItem (multiple) or DropdownMenuRadioItem (single). Focusable-when-disabled.

ToolbarLink[data-slot="toolbar-link"]

A navigation link styled as a ghost button and roving as a toolbar item (composes Radix Toolbar.Link → <a>). Uses aria-disabled (a link takes no native disabled) to stay arrow-reachable while inert.

ToolbarInput[data-slot="toolbar-input"]

A first-class roving text/search member (styled by fieldVariants, optional leading glyph). Arrow/Home/End move the caret rather than roving focus (the editable escape hatch); it is pinned in the bar (never collapses to the menu) while actions overflow. Place it LAST per APG.

ToolbarFloating[data-slot="toolbar-floating"]

The floating (selection / bubble) toolbar — the text-selection bubble pattern. Composes Popover to anchor a variant=floating Toolbar against an element or virtual anchor (a ref exposing getBoundingClientRect). Anchor-only: there is NO built-in trigger element. Focus stays on the selection by default (autoFocus off); the popover surface is stripped to a bare positioner so the inner floating bar owns the elevated look.

Props

The component's public props and their types.

appearance
Description
No description.
Type
"ghost" | "outline" | "soft" | "solid"
Default
outline
orientation
Description
No description.
Type
"horizontal" | "vertical"
Default
horizontal
size
Description
No description.
Type
"lg" | "md" | "sm"
Default
md
variant
Description
No description.
Type
"docked" | "floating"
Default
docked
dir
Description
Text direction of the bar — flips arrow-key travel and where popups open.
Type
"ltr" | "rtl"
Default
"ltr"
loop
Description
Wrap focus first↔last on arrow keys (roving).
Type
boolean
Default
true
overflow
Description
How out-of-space controls are handled: `collapse` measures and moves them into a "more" menu, `scroll` keeps one row and scrolls, `wrap` flows to multiple rows, `visible` never hides them.
Type
"collapse" | "scroll" | "visible" | "wrap"
Default
"collapse"
overflowIcon
Description
Glyph for the collapsed overflow trigger (defaults to a horizontal ellipsis).
Type
React.ReactNode
overflowLabel
Description
Accessible name for the "…" overflow trigger.
Type
string
Default
"More"

Plus 279 inherited native <div> attributes.

ToolbarButton

shortcut
Description
Keyboard shortcut for this action — one descriptor rendered in three places: a `<kbd>` in the tooltip, a shortcut hint in the overflow menu, and (when a string) `aria-keyshortcuts` on the control.
Type
React.ReactNode
tooltip
Description
Visible name hint for an icon-only button; follows orientation / RTL.
Type
React.ReactNode

Plus 303 inherited native HTML attributes.

ToolbarSplitButton

"aria-label"
Description
Accessible name of the PRIMARY half. Required when `label` carries no text (an icon-only primary) — it names the button itself, not this wrapper.
Type
string
align
Description
Menu alignment against the chevron half. Defaults to `end` — the menu is launched from the trailing half, so it hangs back under the split button rather than spilling past it.
Type
"center" | "end" | "start"
childrenRequired
Description
The menu half's contents: `DropdownMenuItem`s and friends.
Type
React.ReactNode
disabled
Description
No description.
Type
boolean
labelRequired
Description
Visible label of the primary half.
Type
React.ReactNode
leading
Description
Icon placed before the primary label.
Type
React.ReactNode
menuLabelRequired
Description
Accessible name of the MENU half. Required, and it must be contextual ("More save options") rather than a repeat of the primary's name — voice control has no other way to tell the two halves apart.
Type
string
onSelect
Description
The primary action — the whole point of the left half.
Type
() => void
shortcut
Description
Keyboard shortcut for the PRIMARY action (tooltip `<kbd>` + aria-keyshortcuts).
Type
React.ReactNode
size
Description
Density rung for the pair. Deliberately the toolbar's own scale rather than Button's: both halves derive from one rung (the menu half taking the square one), so they cannot drift in height.
Type
ToolbarSize
tone
Description
No description.
Type
"brand" | "danger" | "neutral" | "success" | "warning"
tooltip
Description
Visible name hint for the primary half; follows orientation / RTL.
Type
React.ReactNode
variant
Description
Fill of both halves. Defaults to `ghost`, matching `ToolbarButton`.
Type
"default" | "ghost" | "link" | "outline" | "soft"

Plus 278 inherited native HTML attributes.

ToolbarToggleItem

tooltip
Description
Visible name hint for an icon-only toggle; follows orientation / RTL.
Type
React.ReactNode

Plus 291 inherited native HTML attributes.

ToolbarInput

appearance
Description
Field fill; defaults to `soft` to sit quietly inside the bar.
Type
"ghost" | "outline" | "soft"
leading
Description
In-field leading glyph (e.g. a search icon).
Type
React.ReactNode

Plus 310 inherited native HTML attributes.

ToolbarFloating

align
Description
No description.
Type
"center" | "end" | "start"
anchor
Description
Element or virtual anchor to float against — a `ref` to anything exposing `getBoundingClientRect` (a DOM node, or a virtual object tracking a text selection). There is no built-in trigger element; omit it only when placing the bubble yourself, since without an anchor it has no positioning reference.
Type
React.RefObject<ToolbarAnchor | null>
autoFocus
Description
Move focus into the bar on open. Default `false` — keep the caret/selection.
Type
boolean
defaultOpen
Description
No description.
Type
boolean
onOpenChange
Description
No description.
Type
(open: boolean) => void
open
Description
Controlled open state of the bubble.
Type
boolean
overflow
Description
Overflow of the inner bar. Default `visible` — a bubble sizes to its content.
Type
"collapse" | "scroll" | "visible" | "wrap"
side
Description
No description.
Type
"bottom" | "left" | "right" | "top"
sideOffset
Description
No description.
Type
number

Plus 285 inherited native HTML attributes.

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="toolbar"][data-slot="toolbar-overflow-trigger"]

States

SelectorState
[data-elevated]elevated
[data-overflow]overflow
[aria-disabled]disabled
[data-slot=toolbar-split-button] [data-state=open]split-button-menu-open
PropertyTokenTier
height--garn-control-h-mdsemantic · air
clipMargin--garn-space-6semantic · air

When to use

A strip of 3+ related controls (buttons, toggles, links, a search field) that should behave as ONE tab stop with arrow-key navigation — editor formatting bars, app / data-table action bars, vertical tool rails, and selection bubbles — especially when they must stay reachable as the container narrows.

Reach for something else when

  • For 1–2 unrelated actions, use plain Buttons spaced with a gap. To only visually join a small related cluster without a keyboard model, use ButtonGroup. To choose one value (or several) from a set, use ToggleGroup directly. For a bar of dropdown menus (File / Edit / View), use Menubar
  • for a global command surface, use Command.

Overview

Toolbar is the accessible container garn was missing: a single, named region (role=toolbar) that puts a whole strip of related controls on ONE tab stop and navigates between them with the arrow keys — buttons, toggles, toggle-groups, links, a search field, and split buttons, mixed into one coherent bar. It is deliberately thin: a roving-focus + overflow shell that composes garn's Button, Toggle-group, Separator, Dropdown-menu, and Tooltip rather than re-skinning them.

That is the gap ButtonGroup doesn't fill. ButtonGroup visually joins a cluster (role=group, no keyboard model); Toolbar adds the semantic role, the single tab stop with continuous arrow navigation across nested groups, and — its marquee feature — responsive overflow: when the bar runs out of space it measures itself and collapses the out-of-space actions into a '…' more-menu, so an action is never lost and the bar keeps a stable single-row height. The role and roving only switch on at 3+ controls (the APG threshold); a 1–2 control cluster stays a plain group.

Two structural variants share the anatomy. The default docked bar is the flat app / editor / data-table strip. floating (via ToolbarFloating) is the selection bubble — a variant=floating bar anchored to a text selection with Popover, keeping focus on the caret. Density is a token concern (size → gap / padding / radius, control heights from --garn-control-h-*), never per-instance padding.

Guidelines

Always name the bar. A toolbar is one thing in the page tab order, so it needs an accessible name — pass aria-label or aria-labelledby. Icon-only controls each need their own aria-label; pair with tooltip for the visible name.

Let overflow do the work; don't hand-hide. Keep every action in the markup and let overflow=collapse (the default) decide what folds into the more-menu at the current width. Reserve scroll / wrap for bars that must show everything, and visible when you position the bar yourself.

Reach for the toolbar's own parts. Use ToolbarButton / ToolbarToggleGroup / ToolbarSplitButton / ToolbarInput — not the standalone Button / SplitButton — so each control roves correctly and knows how to re-render itself as a menu row when it overflows. A standalone SplitButton in a toolbar breaks the single-tab-stop model; ToolbarSplitButton is the fix.

Place a search field last. A ToolbarInput is pinned (it never collapses) and takes over the arrow keys for the caret while focused, so put it at the end per APG — actions collapse in front of it.

Prefer a soft or padded surface when controls have selection or focus rings. A flush ghost bar gives a focus ring no room; a soft bar also lifts selected toggles to an elevated thumb that reads on the tint.

Best practices

Do
  • Give the toolbar an aria-label (or aria-labelledby).
  • Use the toolbar's own parts (ToolbarButton / ToolbarToggleGroup / ToolbarSplitButton), not standalone Button / SplitButton.
  • Give every icon-only control an aria-label, and a tooltip for the visible name.
  • Leave overflow on collapse and let the bar fold overflowing actions into the more-menu.
  • Place a ToolbarInput last, and give it an aria-label.
  • Name a split button's menu half for what it opens (“More save options”), distinct from the primary.
Don't
  • Don't drop a standalone SplitButton into a toolbar — it isn't a roving item; use ToolbarSplitButton.
  • Don't hand-pad controls with px-* / [Npx]; density comes from size.
  • Don't hardcode role=toolbar on a 1–2 control cluster — that's a plain group, and this component switches roving on only at 3+.
  • Don't hide overflowing actions yourself — let collapse keep them reachable in the menu.
  • Don't put an editable control anywhere but last, or the arrow keys will fight the caret.

Content guidelines

  • Use short, parallel names across the controls; the icon carries the meaning, the aria-label / tooltip carries the words.
  • Group by function and separate groups with a ToolbarSeparator, not whitespace.
  • Order controls by likelihood or convention — the trailing ones are the first to collapse into the menu.

Troubleshooting

No arrow-key navigation and no role=toolbar in the a11y tree — the bar behaves like a plain group.

Cause. Fewer than 3 controls. Toolbar applies role=toolbar + roving only at the APG 3+ threshold; 1–2 controls stay role=group with independent tab stops.

Fix. This is intended. Add a third control for a real toolbar, or accept the plain group for a 1–2 control cluster.

Arrow keys skip a split button, or it adds extra Tab stops mid-bar.

Cause. A standalone SplitButton was placed in the toolbar — its halves keep tabIndex 0 and it counts for nothing toward the roving threshold, silently downgrading the bar.

Fix. Use ToolbarSplitButton instead: it is one roving stop (menu half tabIndex=-1, reachable by the perpendicular arrow) and counts once toward the 3-control gate.

A search / text field disappears into the overflow menu when the bar narrows, or the arrow keys move focus off it instead of the caret.

Cause. The editable wasn't a ToolbarInput, or wasn't placed last.

Fix. Use ToolbarInput and place it LAST. It is pinned (never collapses) and its Arrow/Home/End move the caret via the root's editable escape hatch.

On a soft bar, a selected toggle is hard to see — its fill is the same shade as the bar.

Cause. A tinted (soft) bar collides with the absolute-neutral selected fill.

Fix. Nothing to do — a soft bar sets data-elevated, so selected toggles lift to a card 'thumb' that reads on the tint (WCAG 1.4.11). Don't hand-tint the controls.

A control's focus ring is clipped along the bar's edge, worst on a flush ghost bar.

Cause. The collapse container clips transient item-spill; a ring drawn just outside the box gets caught at the edge.

Fix. The clip-margin (--garn-space-6) already shows a ≤3px ring; give a taller control (a ToolbarInput) room with a padded surface (soft) rather than the flush ghost.

A leading or trailing separator shows after the bar collapses, or two separators sit adjacent in the menu.

Cause. Expecting to hand-manage dividers across the overflow boundary.

Fix. Don't — Toolbar trims a separator that becomes an edge after the cut and de-dupes adjacent ones in the menu automatically. Author separators for the full-width layout.

The toolbar (or an icon-only control, or a split button's menu half) is flagged as having no accessible name in dev.

Cause. A roving toolbar with no aria-label, an icon-only button/input with no aria-label, or a split button whose two halves share one name.

Fix. Name the bar (aria-label / aria-labelledby); give each icon-only control an aria-label; name a split button's menu half for what it opens, distinct from the primary.

Accessibility

Role
toolbar
ARIA APG
toolbar
Focus
Roving tabindex (Radix RovingFocusGroup) — one tab stop, real DOM focus moved by the arrow keys. Focus returns to the overflow / split-button trigger when its menu closes.

Accessibility requirements

error

Tab moves focus onto the toolbar exactly once; a second Tab leaves it. Controls are reached by the arrow keys via roving tabindex, never by Tab.

when role=toolbar (3+ controls)

error

ArrowRight focuses the next control and ArrowLeft the previous; with `loop` (default), ArrowRight past the last wraps to the first.

when a horizontal toolbar

error

The root exposes aria-orientation=vertical; ArrowDown / ArrowUp move between controls and ArrowLeft / ArrowRight do not rove.

when orientation=vertical

error

Home focuses the first control; End focuses the last.

when role=toolbar

error

ArrowLeft moves to the next (visually-left) control — arrow direction follows the locale, not the physical key.

when a horizontal toolbar under dir=rtl

error

A nested group adds no second tab stop; its items are traversed inline within the single toolbar arrow sequence (Shift+Tab is not trapped).

when a ToolbarToggleGroup or ToolbarGroup inside the bar

error

A disabled control still receives focus via the arrows (aria-disabled) but does not activate on Enter / Space / click.

when a disabled ToolbarButton / ToolbarToggleItem / ToolbarLink / ToolbarSplitButton

error

Exposes a non-empty accessible name via aria-label (the icon alone is not a name).

when an icon-only ToolbarButton / ToolbarToggleItem / ToolbarLink

error

Every enum axis reflects to its data-* attribute: data-variant, data-appearance, data-size, data-orientation, data-overflow (and data-elevated on a soft bar).

when always

error

Overflowing controls render inside the '…' dropdown menu and every action stays keyboard-reachable — none are dropped.

when overflow=collapse and the bar is narrower than its controls

warn

The overflow menu entrance animates opacity only — no scale transform.

when prefers-reduced-motion: reduce

error

The input is an arrow-reachable roving member whose Arrow / Home / End move the caret (not focus); it is pinned in the bar while actions collapse and never renders as a menu row.

when a ToolbarInput in a collapsing bar

warn

A toolbar needs an accessible name — pass aria-label or aria-labelledby.

when a roving toolbar with no aria-label / aria-labelledby

warn

A search / text field needs an accessible name — pass aria-label or aria-labelledby (a placeholder is not a reliable name).

when a ToolbarInput with no aria-label / aria-labelledby

error

Adds exactly ONE tab stop: the menu half is tabIndex=-1; the two halves are one roving item reached by the perpendicular arrow axis, and roving-axis keys / Home / End pressed on the menu half are handed back to the bar.

when a ToolbarSplitButton in a roving toolbar

error

aria-haspopup / aria-expanded appear on the menu half only — never on the primary half or the wrapper — and the menu opens from the keyboard.

when a ToolbarSplitButton with a menu half

error

A ToolbarSplitButton counts once toward the roving threshold: two controls plus one stays a plain group; three turns roving on.

when the APG 3-control threshold

error

Both halves stay arrow-reachable and announced aria-disabled; neither activates, and the menu half is not a live trigger at all.

when a disabled ToolbarSplitButton

error

On collapse it becomes a primary menu row plus a submenu named by menuLabel; neither action is lost.

when a ToolbarSplitButton in an overflowing bar

warn

Name the menu half for what it opens (e.g. “More save options”), not a copy of the primary — voice control has no other way to tell the halves apart.

when a ToolbarSplitButton whose menu half name equals the primary half's

warn

An icon-only primary half needs an accessible name — pass aria-label (it names the button, not the wrapper).

when a ToolbarSplitButton with an icon-only primary half and no aria-label

error

It renders a variant=floating toolbar inside a role=dialog popover surface (sharing the bar's accessible name) and does not steal focus from the selection by default (autoFocus off).

when a ToolbarFloating bubble opens

Keyboard

TabShift+TabEnter or leave the toolbar as a whole — one tab stop at 3+ controls. Focus lands on the last-focused (or first) control; a second Tab exits the bar entirely.
ArrowRightArrowLeftArrowDownArrowUpRove to the next / previous control along the orientation axis (→/← horizontal, ↓/↑ vertical). Wraps first↔last when `loop` (default true). Direction follows `dir` — under RTL, ArrowLeft moves to the next (visually-left) control.
HomeEndFocus the first / last control.
EnterSpaceActivate the focused control; on a ToolbarSplitButton's menu half, open its menu.
EscapeClose an open overflow or split-button menu and return focus to its trigger.
ArrowDownArrowUpOn a ToolbarSplitButton, move between the primary and menu halves — on the axis perpendicular to the bar's roving (RTL-mirrored on a vertical rail), so no roving key is stolen.
A2.1.1KeyboardA2.5.3Label in NameA4.1.2Name, Role, ValueAA1.4.11Non-text ContrastAA2.5.8Target Size (Minimum)
  • role=toolbar is applied only at 3+ controls (APG threshold). 1–2 controls render a plain role=group with independent tab stops; 0 controls render neither.
  • Requires an accessible name — pass aria-label or aria-labelledby (dev-warned when missing on a roving toolbar).
  • Single tab stop via roving tabindex (the focused control is tabindex=0, the rest -1), using real DOM focus, not aria-activedescendant.
  • aria-orientation=vertical is emitted only on a vertical rail.
  • Disabled controls stay in the roving sequence as aria-disabled (focusable-when-disabled) rather than being removed from the tab order.
  • Icon-only controls take their accessible name from aria-label; pair with Tooltip for the visible name (the tooltip side follows orientation / RTL).
  • Overflowed controls stay reachable inside the '…' dropdown menu; the trigger carries the overflowLabel accessible name.
  • In a ToolbarSplitButton the menu ARIA (aria-haspopup / aria-expanded) lives ONLY on the menu half, supplied by the dropdown-menu primitive; both halves are separate native buttons.