Toolbar
A bar of mixed controls — buttons, toggles, links, an input, separators — with one tab stop and arrow-key traversal. Five variants, three sizes, five radii, horizontal + vertical, LTR/RTL. Never wraps.
pnpm add @tessinaui/uiWhen to use
- A dense, persistent bar of controls that act on one thing: a text selection, a canvas, a table, the rows picked in a list. It is one tab stop with the arrow keys inside, so a keyboard user passes the whole bar in one press.
- Frequent actions for the current view or task. Put the commands people
reach for most first, and group related ones with
ToolbarGroupand separators. - Mixed controls in one bar: buttons, toggles (
pressed), links and one input, withToolbarSpacerpushing trailing items to the far edge. - Destructive actions kept apart. Mark the item
intent="error"and set it off from its neighbours with a separator or a spacer. - One line. A toolbar never wraps: a bar with more tools than width scrolls along its own axis, or moves the tools people rarely need into an overflow menu.
When not to use
- For a few related actions that should each be their own tab stop. Use a Button Group; a group deliberately has no roving focus.
- For an application's top-level menus (
File,Edit,View), where every item opens a menu. Use a Menubar. - To pick one of a few options that changes a value or a view setting. Use a Segmented Control. Inside a toolbar, a cluster with real selection state is a Toggle Group composed into the bar.
- To switch between views. Selecting a view changes context rather than acting on the current one. Use Tabs.
- For a bar that rides above the software keyboard on a phone. Use a Keyboard Toolbar; it wraps a normal toolbar.
- To hold every action a page has. A bar crowded past the point where each item can be told apart and hit helps nobody; keep the common tools visible and move the rest behind a menu.
Installation
pnpm add @tessinaui/uiUsage
import {
Toolbar,
ToolbarButton,
ToolbarGroup,
ToolbarLink,
ToolbarInput,
ToolbarSeparator,
ToolbarSpacer,
} from "@tessinaui/ui";{/* Formatting bar — `pressed` derives `aria-pressed`, so state is announced. */}
function FormatToolbar() {
const [bold, setBold] = useState(true);
const [italic, setItalic] = useState(false);
const [align, setAlign] = useState("left");
return (
<Toolbar aria-label="Format text">
<ToolbarButton iconOnly leadingIcon={<Undo2 />} aria-label="Undo" />
<ToolbarSeparator />
<ToolbarButton iconOnly leadingIcon={<Bold />} aria-label="Bold"
pressed={bold} onClick={() => setBold((v) => !v)} />
<ToolbarButton iconOnly leadingIcon={<Italic />} aria-label="Italic"
pressed={italic} onClick={() => setItalic((v) => !v)} />
<ToolbarSeparator />
<ToolbarButton iconOnly leadingIcon={<AlignLeft />} aria-label="Align left"
pressed={align === "left"} onClick={() => setAlign("left")} />
<ToolbarButton iconOnly leadingIcon={<AlignCenter />} aria-label="Align center"
pressed={align === "center"} onClick={() => setAlign("center")} />
<ToolbarSpacer />
<ToolbarLink href="/help">Help</ToolbarLink>
</Toolbar>
);
}
{/* Table action bar — the input goes LAST, and the bar scrolls when narrow. */}
<Toolbar variant="outline" overflow="scroll" aria-label="Table actions">
<ToolbarButton leadingIcon={<Filter />} trailingIcon={<ChevronDown />}>Filter</ToolbarButton>
<ToolbarButton leadingIcon={<Plus />}>Add row</ToolbarButton>
<ToolbarSpacer />
<ToolbarInput placeholder="Search…" aria-label="Search rows" />
</Toolbar>
{/* Floating canvas bar */}
<Toolbar variant="floating" rounded="full" aria-label="Selection tools">
<ToolbarButton iconOnly leadingIcon={<Bold />} aria-label="Bold" pressed />
<ToolbarButton iconOnly leadingIcon={<Italic />} aria-label="Italic" />
</Toolbar>Examples
Default
Icon buttons in labelled groups, divided by separators.
Variants
Five surfaces — soft (default, a recessed well), solid, outline, ghost, and floating for a bar detached from what it sits over.
Sizes
sm, md and lg drive the item heights — 32 / 36 / 40px, putting the bar at 40 / 44 / 48.
Rounded
Every corner derives from one rounded value: the bar, each item and a contained group step together, so the items always tuck inside the bar's silhouette.
Groups
ToolbarGroup clusters related items and names them for assistive technology. variant="contained" gives the cluster its own tinted well. On a colour plate over a soft or floating bar, which already is that well, it outlines itself instead of stacking a second wash.
Pressed
pressed paints the raised plate and derives aria-pressed. The bar does not own the state — these are independent toggles held by the consumer.
Real toggle state
When a cluster needs controlled state, single- or multi-selection and a change event, compose a ToggleGroup inside the bar rather than wiring pressed by hand.
Overflow — scroll
A toolbar never wraps to a second line, so a bar with more tools than width becomes a rail along its own axis. This is the default, because it costs nothing while the items fit and it is what every phone editor ships.
Overflow — paging chevron
A rail only announces itself once you touch it. overflowAffordance pins a chevron at the end that pages the bar, so the hidden items are visible as a target rather than as a guess.
It renders only while the content actually overflows (measured with a ResizeObserver and a MutationObserver, so an item added later still counts), and it turns round at the end rather than going dead. Switching it on moves the items into an inner toolbar-rail element so the button can sit beside the rail instead of scrolling away with it — which is why it is opt-in: a bar that fits should not pay for the extra element.
Needs overflow="scroll" (the default). Under overflow="none" the bar clips, so there is nothing to page to, and the prop warns in development instead of rendering a button that cannot work.
Overflow — menu
The alternative: keep the frequent tools visible and put the rest behind a trailing menu. Unlike a rail, nothing ends up off-screen.
Menus
A menu opened from the bar. The trigger is a ToolbarButton passed through the menu's render, so it keeps its place in the roving focus instead of becoming a second tab stop.
Floating
A bar with a surface of its own, lifted off the content. Pinned to the bottom of a phone screen with the safe-area inset, this is the keyboard accessory bar.
Search and actions
Leading tools, trailing actions, and the real Search component filling the rest of the bar, so the field never ends up squeezed into the corner. Reach for ToolbarSpacer instead when the trailing item should keep its natural size.
Search reaches the bar through ToolbarInput's render: its input joins the bar's roving focus — arrows still traverse, Tab still leaves — while Search keeps the magnifier, Escape-to-clear, the clear button, the iOS search keyboard and its polite result-count region. Pass noForm (Search is a <form> by default, and nested forms are invalid HTML), size it from inputFieldSizeFor[size] so it matches item height at every bar density, and give it a layout box through wrapperClassName — Search's root is w-full and would otherwise claim the whole row.
Selection bar
A count, the actions that apply to it, and a way out — the batch-action bar over a list or a grid. The destructive action takes intent="error".
Labeled items
The stacked icon-over-label item media editors ship. A className recipe rather than a prop; a stacked item clears the 44px touch floor on its own.
In flight
loading announces aria-busy, swallows activation and shows the spinner — while the item stays focusable, so a request in progress never throws the user's focus back to the start of the bar.
Vertical
orientation="vertical" stacks the items into a rail. At rounded="full" the bar derives a stadium radius from its own item height rather than clipping into an oval.
RTL
dir="rtl" mirrors the layout and flips the arrow keys. Unset, the bar does the same on an RTL page.
Disabled
A disabled item stays reachable by arrow key, so a user can discover that a tool exists and is currently unavailable. disabled on the bar cascades to every item.
On color
Inside a coloured Surface the bar inherits on-color: a soft or floating bar paints the plate's well, and a pressed item becomes the plate's inverse chip. Pass tone to override the inherited value.
Skeleton
The placeholder paints from the same cvas the real bar renders with, so its geometry cannot drift from the component.
Composition
The toolbar is a focus-traversal container: arrow keys move between items, Tab leaves for the next region. Any focusable primitive composes inside it.
Give the bar a name. A screen reader announces the role and then has nothing to say about which bar this is. In development, an unnamed Toolbar — or an unnamed iconOnly item — logs a warning.
Toggle state. pressed on a ToolbarButton is a per-item visual flag that derives aria-pressed for you:
const [bold, setBold] = useState(false);
<ToolbarButton
iconOnly
leadingIcon={<Bold />}
aria-label="Bold"
pressed={bold}
onClick={() => setBold((v) => !v)}
/>For a cluster that needs real state — controlled or uncontrolled, single- or multi-select, with a change event — compose ToggleGroup inside the bar instead. Pair the sizes deliberately: the toolbar's sm / md / lg items are 32 / 36 / 40px, which line up with ToggleGroup's xs / xs / sm.
Menus, selects and tooltips compose through Base UI's render, which keeps the trigger inside the roving focus:
{/* A menu opened from the bar */}
<DropdownMenuTrigger
render={<ToolbarButton iconOnly leadingIcon={<MoreHorizontal />} aria-label="More tools" />}
/>
{/* A tooltip — note the direction reverses: the item goes INTO the trigger */}
<Tooltip>
<TooltipTrigger render={<ToolbarButton iconOnly leadingIcon={<Bold />} aria-label="Bold" />} />
<TooltipContent>Bold ⌘B</TooltipContent>
</Tooltip>An icon-only item needs both a tooltip (for sighted users) and an aria-label (for everyone else). The tooltip is not an accessible name.
API Reference
Toolbar (root)
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "solid" | "soft" | "outline" | "ghost" | "floating" | "soft" | Bar chrome. floating is a detached, shadowed surface. |
size | "sm" | "md" | "lg" | "md" | Bar inset, gap and item height (32 / 36 / 40px). |
rounded | "none" | "sm" | "md" | "lg" | "full" | "md" | Corner radius; items and contained groups derive from it. |
orientation | "horizontal" | "vertical" | "horizontal" | Layout axis. |
overflowAffordance | boolean | false | Pin a paging chevron at the end, shown only while the content overflows. Requires overflow="scroll". |
overflowAffordanceLabels | { forward?: string; back?: string } | { forward: "Scroll forward", back: "Scroll back" } | Accessible names for the affordance — override for i18n. |
overflow | "none" | "scroll" | "scroll" | What happens when the items exceed the bar. scroll is a rail along the bar's own axis and costs nothing while the items fit; none clips instead. The bar never wraps either way. |
tone | "default" | "on-color" | inherited | Ink and chrome for the ground under the bar. Inherited from an enclosing Surface. A solid bar, or any bar on a coloured plate, passes on-color to its children, so a Button, Badge or Link among them adapts too. A soft, outline or floating bar told tone="default" on a plate passes default, so they match its light bar; a ghost bar passes on nothing. |
loopFocus | boolean | true | Wrap arrow-key focus at the ends. |
disabled | boolean | false | Disable every item in the bar. |
dir | "ltr" | "rtl" | inherited | Reading direction, arrow keys included. Unset, the bar follows the page. |
aria-label | string | — | Required in practice; warns in development when absent. |
ToolbarButton
| Prop | Type | Default | Description |
|---|---|---|---|
iconOnly | boolean | false | Square item, no horizontal padding. Needs an aria-label. |
leadingIcon | ReactNode | — | Icon before the content. |
trailingIcon | ReactNode | — | Icon after the content. |
pressed | boolean | — | Toggle state. Renders the raised plate and derives aria-pressed. Leave unset for a plain action button. |
loading | boolean | false | Work in flight: aria-busy, activation swallowed, item stays focusable. |
intent | "none" | "error" | "none" | What the action means. error marks a destructive item and keeps its ink through hover and press, on the page and on a coloured plate. A pressed item keeps the shared plate; a disabled one drops the intent and dims like its neighbours. |
disabled | boolean | false | Disable this item. |
focusableWhenDisabled | boolean | true | Keep a disabled item in the arrow-key sequence (Base UI default). |
ToolbarLink
The same shape as ToolbarButton, rendering an <a>. Pass href and the usual anchor attributes.
ToolbarInput
| Prop | Type | Default | Description |
|---|---|---|---|
invalid | boolean | false | Tints the boundary and sets aria-invalid. Ignored under render — a composed field owns its own validity chrome. |
render | ReactElement | — | Put a composite field in the bar's roving order instead of the bare input. Under render this part contributes no chrome: the composed component brings its own border, fill, height and radius. |
Place at most one, last. Left/right arrows move the caret as well as traversing the bar, so items after an input are reachable only once the caret has run to the end of the value. A second input in one bar warns in development.
A composite may mount controls of its own inside the field — Search's clear button appears once the value is non-empty — and those are ordinary tab stops. An untouched bar is still one tab stop; a dirty one is two.
ToolbarGroup
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "none" | "contained" | "none" | none groups for ARIA and spacing; contained adds a tinted well. |
disabled | boolean | false | Disable every item in the group. |
aria-label | string | — | Recommended on any group that is not self-evident. |
ToolbarSeparator
Orientation-aware rule: a vertical line in a horizontal bar, a horizontal one in a vertical bar. Inset off both ends and deliberately fainter than --border, which reads as a wall between buttons.
ToolbarSpacer
A spring that pushes everything after it to the trailing edge. It is Spacer with grow, on the bar's own axis, and keeps its own data-slot="toolbar-spacer". Under overflow="scroll" it collapses to zero before the rail starts scrolling — fill the bar, then scroll.
ToolbarSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
items | number | 4 | Placeholder items. |
groups | number[] | — | Items per cluster, separated as the real bar is. Overrides items. |
spacer | boolean | false | A flex spacer before the trailing items. |
labelledItems | number | 0 | Trailing items that carry a text label. |
Plus size, variant, rounded, orientation and tone, which mirror the real bar.
Notes
- A toolbar never wraps. A second row destroys the single-line scan the bar exists for, and the alternative broke
ToolbarSpacer: once a row wrapped, the spacer took a line of its own and stopped pushing. What it does instead isoverflow's business —scroll(the default) rails,noneclips. Neither lets content escape, because an unclipped bar pushes the whole page sideways, which is WCAG 1.4.10: measured at up to 98px on six of the examples below before the clip existed. To keep every action visible at any width, move the surplus into an overflow menu. - Pressed items render as a raised plate lifted off the well — the same treatment
SegmentedControlgives its thumb, so the two read as one family. On a page-coloured bar (outline,floating,ghost) nothing can lift above the background, so the pressed state recesses into the well instead. It stays visible in Windows high-contrast. On a coloured plate both are the inverse chip — the plate's own ink, labelled with the neutral that contrasts it — because a wash of the ink cannot hold a label at 4.5:1 on the error, info and success plates. - RTL — on an RTL page the bar mirrors its layout and its arrow keys with no prop.
dirsets a direction the page around it does not have. - Touch — every item clears 44×44px wherever touch is the pointer, phones and tablets alike (the input included, which its own
flex-1would otherwise squeeze to 26px), while a mouse or trackpad keeps the compact density, andToolbarInputrenders at 16px on touch so iOS Safari does not zoom the page on focus.
Accessibility
Pattern: WAI-ARIA Toolbar.
- One tab stop, arrow keys inside. Built on
@base-ui/react/toolbar, the bar rendersrole="toolbar"witharia-orientationand a roving tabindex:Tabenters on one item and leaves for the next region, arrows move between items. A rail underoverflow="scroll"scrolls the focused item into view. - Name the bar. Pass
aria-label(oraria-labelledby); an unnamedToolbar, or an unnamediconOnlyitem, warns in development. Name aToolbarGroupwhose purpose is not self-evident. An icon-only item needs both a tooltip and anaria-label— the tooltip is not an accessible name. - Toggle state is announced.
pressedderivesaria-pressedbefore your props spread, so a stray prop cannot desynchronise the announced state from the painted one.loadingsetsaria-busy, swallows activation and keeps the item focusable. - Disabled items stay focusable by default, matching Base UI and WAI-ARIA: a user arrowing along the bar should be able to discover that a tool exists and is unavailable, rather than have it silently vanish.
- The paging chevron is part of the bar.
overflowAffordancerenders a named button inside the item list, and its name flips between "Scroll forward" and "Scroll back" as the rail reaches an end (overflowAffordanceLabelslocalises both). - Separators are orientation-aware and convey the grouping to assistive tech.
ToolbarInputshares the arrow keys with the caret — place at most one, last (seeToolbarInput).- Contrast — pressed state stays visible in Windows high contrast; on a
coloured plate the pressed chip is the plate's own ink with a contrasting label,
because a wash cannot hold 4.5:1 on the error, info and success plates. axe
color-contrastis clean on the demo and playground in both themes. - Touch — every item clears 44×44px on touch devices, phones and tablets alike.
Keyboard
| Key | Behaviour |
|---|---|
Tab / Shift+Tab | Enters the bar on one item; the next press leaves it. A composed field's own controls (Search's clear button) add a stop |
← / → | Previous / next item in a horizontal bar; wraps at the ends while loopFocus is on. Flipped under dir="rtl" |
↑ / ↓ | Previous / next item in a vertical bar |
Enter / Space | Activates the focused item (a pressed item's onClick flips its state) |
Home / End | Not bound — Base UI's toolbar composite does not expose them, and APG makes them optional where arrows wrap |
AT matrix
| AT | Status |
|---|---|
| VoiceOver + Chrome (macOS) | Passed (at delivery, before the 2026-09-08 round) — "Undo button Text style group", "Bold toggle button", "selected Bold toggle button". The loading item was not captured; aria-busy is pinned by a unit test instead |
| VoiceOver + Safari (macOS) | Passed 2026-09-09 — the bar reads as a toolbar with its name and item count; the paging chevron is a named button whose name flips on activation; separators read as "vertical splitter"; a composed Search reads as one named search landmark with its clear button as the next stop. Whether VO voices Search's result-count live region was not isolated |
| NVDA + Chrome (Windows) | UNTESTED — no Windows host available |
| JAWS | UNTESTED |
VoiceOver steps past a toolbar as one unit: Control+Option+Shift+↓ interacts
into it. One NVDA pass is still owed.