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.
@garn/ui/toolbarDefault
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.
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.
Search
A ToolbarInput pinned at the end: the search field stays put while the actions before it collapse.
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.
npx garn-ui add toolbar- Registry
toolbar- Source
- toolbar.tsx
- Deps
@radix-ui/react-toolbarclass-variance-authoritylucide-react- Registry deps
utilsfield-variantsuse-resize-observeruse-isomorphic-layout-effectuse-merged-refbuttonbutton-groupkbdpopovertoggletoggle-groupseparatortooltipdropdown-menu
Import
import { Toolbar } from "@garn/ui/toolbar";Props
Generated from the component's TypeScript 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
- No description.
- Type
"ltr" | "rtl"
loop- Description
- Wrap first↔last on arrow keys (roving). Default `true`.
- Type
boolean
overflow- Description
- Wrap-first, scroll, keep-visible, or (default) measure & collapse to a menu.
- Type
"collapse" | "scroll" | "visible" | "wrap"
overflowIcon- Description
- Glyph for the collapsed overflow trigger (default: a horizontal ellipsis).
- Type
React.ReactNode
overflowLabel- Description
- Accessible name for the "…" overflow trigger.
- Type
string
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 remap these tokens to restyle without forking the component.
[data-slot="toolbar"][data-slot="toolbar-overflow-trigger"]| Property | Token | Tier |
|---|---|---|
height | --garn-control-h-md | semantic · air |
clipMargin | --garn-space-6 | semantic · air |
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
- 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
collapseand 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 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
collapsekeep 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.
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.
Enforceable contracts
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)
ArrowRight focuses the next control and ArrowLeft the previous; with `loop` (default), ArrowRight past the last wraps to the first.
when a horizontal toolbar
The root exposes aria-orientation=vertical; ArrowDown / ArrowUp move between controls and ArrowLeft / ArrowRight do not rove.
when orientation=vertical
Home focuses the first control; End focuses the last.
when role=toolbar
ArrowLeft moves to the next (visually-left) control — arrow direction follows the locale, not the physical key.
when a horizontal toolbar under dir=rtl
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
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
Exposes a non-empty accessible name via aria-label (the icon alone is not a name).
when an icon-only ToolbarButton / ToolbarToggleItem / ToolbarLink
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
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
The overflow menu entrance animates opacity only — no scale transform.
when prefers-reduced-motion: reduce
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
A toolbar needs an accessible name — pass aria-label or aria-labelledby.
when a roving toolbar with no aria-label / aria-labelledby
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
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
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
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
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
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
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
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
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+Tab | Enter 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. |
| ArrowRightArrowLeftArrowDownArrowUp | Rove 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. |
| HomeEnd | Focus the first / last control. |
| EnterSpace | Activate the focused control; on a ToolbarSplitButton's menu half, open its menu. |
| Escape | Close an open overflow or split-button menu and return focus to its trigger. |
| ArrowDownArrowUp | On 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. |
- • 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.
- Status
- Stable
- Version
v0.3.0- Introduced
v0.2.0
garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.