Toolbar
A container for a set of controls that belong together — formatting buttons, an
editor's tools, a row of page actions. Renders a <div role="toolbar">.
The point of the component is the keyboard contract, not the row. Tab moves
past the whole bar in one press and the arrow keys move between its controls,
which is what stops a nine-button formatting bar from costing nine presses to
skip. A <div> with display: flex gets you the same picture and none of that.
Import
import { Toolbar } from "@forte-ui/react";<Toolbar.Root aria-label="Formatting">
<Toolbar.Group>
<Toolbar.Button iconOnly aria-label="Undo">…</Toolbar.Button>
<Toolbar.Button iconOnly aria-label="Redo">…</Toolbar.Button>
</Toolbar.Group>
<Toolbar.Separator />
<Toolbar.Input aria-label="Search" placeholder="Find…" />
<Toolbar.Link href="/history">History</Toolbar.Link>
<Toolbar.Button variant="solid" tone="primary">Publish</Toolbar.Button>
</Toolbar.Root>Examples
Groups and separators
A Toolbar.Group is a visual and semantic cluster, not a keyboard one: the
arrow keys still run the length of the whole bar. What changes is the gap —
tighter inside a group than between them — so the eye groups the items before it
reads them. Reach for a Toolbar.Separator between groups that answer different
questions, and let the group gap carry the rest.
There is no prop for pushing a cluster to the far end, because there is nothing
for one to do that a margin does not: the bar is a flex container, so
className="ms-auto" or style={{ marginInlineStart: "auto" }} on the group
does it.
Variants
variant decides how much chrome the bar carries, and says nothing about
the controls inside: a plain bar can still hold solid buttons, and a panel
bar quiet ones.
plain also drops the padding to zero, and that is the point of it. With no
surface to sit on, the padding would push the first control away from whatever
the bar is aligned with — a heading, a text column, the editor below it — and a
row of buttons that does not line up with the thing it acts on reads as a
mistake.
elevated is the floating bar — a formatting strip over an editor, a tool
palette over a canvas, an action row pinned to the bottom of the viewport. It
is outline with a shadow under it, and it paints that lift and nothing else:
no positioning, no stacking. Fix or stick the bar where you need it yourself,
and to follow a text selection, put a Toolbar inside a
Popover instead.
Sizes
size on the root is the default size of every Toolbar.Button,
Toolbar.Input, Toolbar.Link and Toolbar.Text inside it, so a bar stays one
height without repeating the prop per control. It reaches the library's other
controls too: a Toggle, a
Select.Trigger or a Combobox
rendered into the bar — render={<Select.Trigger />} on a Toolbar.Button —
takes the bar's size with no prop of its own, which is what the editor demo
above relies on. An item's own size still wins, and for a toggle its group's
comes before the bar's.
This is React context rather than CSS inheritance, and it has to be. The size
knobs are declared on each control's own root rule — the placement that lets you
re-skin a single control, because an element's own declaration beats an
inherited one — so a value set on the bar would be inherited and then
immediately overwritten. The data-size attribute the rules key off is resolved
in JavaScript and written onto each item.
Orientation
orientation="vertical" stacks the items and moves the arrow keys onto the
block axis. Separators flip with it: Base UI gives a rule the orientation
perpendicular to the bar's, so a vertical toolbar gets horizontal rules
without being told.
A ToggleGroup inside it needs telling too. Base UI's ToggleGroup skips its
own roving focus in a toolbar, but orientation still drives its layout —
left at the default it lays its toggles out in a row inside your column.
Menus, selects and toggles
Anything can join the bar's arrow-key order, and there are two directions to compose in. Which one to reach for depends on whether the other component has a look of its own to defend.
Toolbar.Button render={<Select.Trigger />} is the direction for a component
that must keep its own appearance — a select trigger has to read as a select.
The default Button styling steps aside when render is present, so the two
never fight over the cascade, and only the toolbar membership is added.
Menu.Trigger render={<Toolbar.Button />} is the direction for a trigger with
no look to defend: Menu.Trigger, Dialog.Trigger, Popover.Trigger and
Drawer.Trigger all step aside for a render of their own, so this hands them
the bar's quiet styling. The toolbar button reads the toolbar's context from
where it sits, so disabled on the root still reaches it either way.
Text inputs
Toolbar.Input is a text field that is part of the roving focus. The arrow keys
do the right thing without configuration: while the caret has somewhere left to
go inside the field, Left and Right move the caret; at either end they hand the
key back to the toolbar and focus moves on. Home and End are never taken — a
toolbar does not bind them — so they jump the caret as they would in any field.
That handling is the reason to reach for it rather than dropping a bare
Input into the bar — a bare one is not in the roving
order at all, so Tab is the only way past it, and you have just paid for the
tab stop the toolbar exists to save.
Links
Toolbar.Link is drawn as a link and not as a button on purpose: it navigates,
and the underline is the only cue that says so before it is clicked. Color
alone would fail SC 1.4.1 against the buttons beside it, which share the bar's
text color.
Use render for a router link — render={<Link href="/docs" />}.
Static text
Toolbar.Text is text that sits in the bar without joining it — a page count,
a zoom readout, the word before a control. It is not an item: it takes no
focus, and the arrow keys pass over it the way they pass over a separator.
What it adds over a bare <span> is the bar's own measure. The text is as tall
as the controls beside it and sized with them, which keeps one baseline across
the bar, and it is muted, so a readout does not compete with the actions around
it.
Disabled
disabled on the root reaches every item; on a Toolbar.Group it reaches that
group's items and composes with the root's.
A link is the one thing disabled cannot reach: HTML has no disabled state for
an <a>, so it stays live and focusable inside a disabled toolbar. Render it
conditionally if it has to go away.
In an editor
Everything the component is for, in one bar.
Accessibility
| Key | Behavior |
|---|---|
| Tab | Moves focus into the toolbar, landing on the last-focused item — or the first one — and out again in one press. The whole bar is a single tab stop. |
| ArrowRight then ArrowDown | Moves focus to the next item, wrapping from the last to the first. Only the arrows on the toolbar's own axis are bound. |
| ArrowLeft then ArrowUp | Moves focus to the previous item, wrapping from the first to the last. |
| Home then End | Not bound. Unlike Toggle Group and Tabs, a toolbar leaves these to the page — or to the caret, when the focused item is a text input. |
| Space then Enter | Activates the focused item. Moving focus never activates anything. |
Set loopFocus={false} to stop the arrows wrapping at the ends.
Each icon-only item still needs a name of its own — an icon is not an accessible
name — and iconOnly holds the 24×24 minimum hit target from SC 2.5.8 for you.
Theming
| Property | Controls | Default |
|---|---|---|
--forte-toolbar-gap | Gap between items. size re-points it. | var(--forte-space-2) |
--forte-toolbar-padding | Padding inside the bar. size re-points it; variant="plain" drops it to 0. | var(--forte-space-2) |
--forte-toolbar-radius | Corner radius, capped so the corner stays concentric with the item in it. variant="plain" drops it to 0. | min( var(--forte-radius-control), calc( var(--forte-toolbar-item-h) / 2 + var(--forte-toolbar-padding) + var(--forte-toolbar-border-width) ) ) |
--forte-toolbar-bg | Fill behind the bar. variant sets it. | transparent |
--forte-toolbar-border-width | Border width. | 1px |
--forte-toolbar-border-color | Border color. variant sets it. | transparent |
--forte-toolbar-shadow | Drop shadow. none except on elevated — a toolbar is part of the page unless it says otherwise. | none |
The bar always draws a 1px border, even when it has no color. transparent is
not preserved under forced colors — it is replaced with a system color — so
declaring the border up front is what lets a panel bar keep a visible edge in
high contrast, where its fill disappears, without the accessibility rules having
to add a border and shift every item in the bar.
Group
| Property | Controls | Default |
|---|---|---|
--forte-toolbar-group-gap | Gap between items inside the group. Tighter than the bar's own gap, which is what makes a cluster read as one. | var(--forte-space-1) |
Separator
| Property | Controls | Default |
|---|---|---|
--forte-toolbar-separator-thickness | Line thickness. | 1px |
--forte-toolbar-separator-color | Line color. | var(--forte-color-border) |
--forte-toolbar-separator-inset | How far the rule stops short of the bar's inner edges. | var(--forte-space-1) |
--forte-toolbar-separator-min-length | Length floor for a rule with nothing to stretch against. | 1lh |
The rule is painted with a border, never a background-color. A background
is replaced with a system color under forced colors, so a hairline drawn that
way vanishes for exactly the users who most need the grouping cue; border
colors are forced to CanvasText instead.
Link
| Property | Controls | Default |
|---|---|---|
--forte-toolbar-link-height | Height. size re-points it. | var(--forte-control-h-md) |
--forte-toolbar-link-padding-x | Inline padding. | var(--forte-space-2) |
--forte-toolbar-link-radius | Corner radius — for the hover fill and the focus ring, which both need to match the buttons beside it. | var(--forte-radius-control) |
--forte-toolbar-link-font-size | Label size. size re-points it. | var(--forte-font-size-2) |
--forte-toolbar-link-color | Label color. | var(--forte-color-primary-text) |
--forte-toolbar-link-bg-hover | Fill on hover. | var(--forte-color-panel-hover) |
--forte-toolbar-link-underline-color | Underline color. | color-mix( in oklab, currentcolor 45%, transparent ) |
--forte-toolbar-link-underline-offset | Distance between the text baseline and its underline. | 0.15em |
--forte-toolbar-link-duration | Color transitions between states. | var(--forte-duration-fast) |
Text
| Property | Controls | Default |
|---|---|---|
--forte-toolbar-text-height | Height. size re-points it. | var(--forte-control-h-md) |
--forte-toolbar-text-padding-x | Inline padding. | var(--forte-space-2) |
--forte-toolbar-text-font-size | Font size. size re-points it. | var(--forte-font-size-2) |
--forte-toolbar-text-color | Color. Muted, so a readout does not compete with the actions around it. | var(--forte-color-foreground-muted) |
The controls inside the bar are themed through their own properties —
--forte-button-* on Button, --forte-input-* on
Input, --forte-toggle-* on Toggle.
API reference
Toolbar.Root
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
disabled | boolean | false | Disable every item in the toolbar. Items stay focusable — see the note on `Toolbar.Button`'s `focusableWhenDisabled`. |
orientation | ToolbarOrientation | horizontal | Direction the items are laid out in, and the axis the arrow keys move along. This is not layout only: Base UI binds the arrows to the named axis, so a vertical toolbar answers to Up and Down and leaves Left and Right to the page — and to any text input inside it. |
size | ToolbarSize | md | Size of the bar, and the default size of every `Toolbar.Button`, `Toolbar.Input`, `Toolbar.Link` and `Toolbar.Text` inside it — and of a `Toggle`, `Select.Trigger` or `Combobox` rendered into the bar, which read the same context. An item's own `size` still wins. |
variant | ToolbarVariant | panel | How much chrome the bar itself carries. `plain` is a bare row with no surface — right when the toolbar sits on a panel that already has one. `panel` fills; `outline` draws a border on the page background; `elevated` adds a shadow to that edge, for a bar floating over content — a canvas, an editor, the bottom of a viewport. It paints the lift and nothing else: where the bar sits, and what it stacks above, is yours. It says nothing about the controls inside: a `plain` bar can still hold solid buttons, and a `panel` bar quiet ones. |
wrap | boolean | false | Let the items wrap onto more than one line when the bar runs out of room, instead of overflowing it. Roving focus follows DOM order either way, so wrapping costs the keyboard nothing. Ignored while `orientation="vertical"`. |
Toolbar.Group
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
disabled | boolean | false | Disable every item in the group. Composes with the root's `disabled` — either one being true disables the item. |
Toolbar.Button
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
focusableWhenDisabled | boolean | true | Keep the item focusable while it is disabled, rather than removing it from the bar's roving focus. `true` by default, and that default is the ARIA authoring practice for a toolbar rather than a stylistic choice: a disabled control that vanishes from the arrow-key order silently changes what every other key press does, and the user has no way to find out *why* an action they expected is missing. Focusable-and-disabled keeps it announced, with its disabled state, in the position they expect it. |
iconOnly | boolean | false | Render as a square button sized for a single icon, holding the 24×24 minimum hit target from WCAG SC 2.5.8. Always pair with `aria-label` — an icon is not an accessible name. Ignored when `render` is set. |
loading | boolean | false | Show a busy indicator and block interaction. Ignored when `render` is set. |
loadingLabel | string | Loading | Announced to assistive technology while `loading` is true. Ignored when `render` is set. |
render | ReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ToolbarButtonState> | Replaces the rendered button with another element or component. This is how a menu, a select, a dialog or a toggle joins the bar's keyboard order — `render={<Toggle iconOnly />}`, `render={<Select.Trigger />}`. The default `Button` styling steps aside when this is present, so the two never fight over the cascade; the rendered component keeps its own look. It still takes the bar's `size` — a `Toggle`, `Select.Trigger` or `Combobox.Trigger` reads the same context this button does — so `render={<Select.Trigger />}` needs no size of its own. | |
size | ButtonSize | the root's `size` | Size of the button. Inherited from `Toolbar.Root` when left unset. Ignored when `render` is set. |
tone | ButtonTone | neutral | Which semantic color set the button draws from. `neutral` by default, for the same reason `variant` is `ghost`. Ignored when `render` is set. |
variant | ButtonVariant | ghost | How much visual weight the button carries. Defaults to `ghost` rather than `Button`'s `solid`: a toolbar is a strip of peers, and a row of solid buttons has no hierarchy left to spend on the one that matters. Give the primary action `variant="solid"` explicitly. Ignored when `render` is set — the rendered component brings its own styling. |
Every other Button prop passes through unchanged, as do
Base UI's own nativeButton and render.
Toolbar.Link
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
size | ToolbarSize | the root's `size` | Size of the link, matching the controls beside it. Inherited from `Toolbar.Root` when left unset. |
Toolbar.Input
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
fullWidth | boolean | false | Stretch the input to fill the space left over in the bar. Ignored when `render` is set. |
render | ReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ToolbarInputState> | Replaces the rendered input with another element or component. The default `Input` styling steps aside when this is present. | |
size | ToolbarSize | the root's `size` | Size of the control. Inherited from `Toolbar.Root` when left unset. This shadows the native `size` attribute, exactly as `Input`'s does. Ignored when `render` is set. |
variant | InputVariant | outline | How much visual weight the control carries. Ignored when `render` is set. |
Toolbar.Text
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
size | ToolbarSize | the root's `size` | Size of the text, matching the controls beside it. Inherited from `Toolbar.Root` when left unset. |
Toolbar.Separator
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
orientation | ToolbarOrientation | Which way the rule runs. Defaults to the *opposite* of the toolbar's orientation — a horizontal bar gets vertical rules — which is almost always what you want, so leave it unset. |