Toolbar
The action strip for a region. One tab stop, arrow keys inside, dividers that group, and overflow that degrades in a published order.
Also called Action Bar, Bulk Action Bar, Editor Toolbar, Formatting Bar — in this system all of them are Toolbar.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Bulk-action bar
The most common toolbar in an admin product. It appears when a selection exists, states the count first, and puts the destructive action past a divider at the far end.
What dividers buy
The same twelve controls, grouped and ungrouped. Nothing changed but four hairlines, and the strip went from a wall to four decisions.
Labelled controls
When the toolbar has room and the actions are infrequent, text labels beat icons. Recognition beats recall, and an unfamiliar glyph is recall.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Two groups and an overflow trigger. The strip is one tab stop; arrow keys move between the seven controls inside it.
- Strip height44px (36px compact)
32px controls plus 6px of padding. Compact drops the padding, not the control, so the touch target never falls below its minimum.
- Control size32 × 32px
One step below the default button. A toolbar is dense by nature and full-size buttons make the strip dominate the region it belongs to.
- Gap4px inside a group
Tight enough that a group reads as one unit, wide enough that two adjacent focus rings never touch.
- Divider1px × 20px, 8px margins
Shorter than the strip so it reads as a separator rather than a border. The 8px margins are double the intra-group gap — that ratio is what makes the grouping legible.
- Overflow triggerLast position, always
Fixed at the end. If it moves as the window resizes, the one control users need to find under pressure is the one that will not stay still.
- Roving tabindexOne 0, rest −1
The defining property. Tab enters once and leaves once; arrows move inside. It also remembers the last-focused control, so returning to the toolbar does not reset to the start.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface | — | Strip background |
| --ds-border-subtle | — | Strip edge and dividers |
| --ds-fg-secondary | — | Idle control glyphs |
| --ds-layer-hover | — | Hover fill on a control |
| --ds-accent-subtle | — | Pressed control fill |
| --ds-accent-text | — | Pressed control glyph |
| --ds-danger-text | — | A destructive control, placed last |
| --ds-focus-ring | — | Focus outline on the roving item |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-1 | Gap inside a group | |
| --space-2 | Divider margins — double the intra-group gap |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Control corners | |
| --radius-lg | Strip corners |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Hover and press transitions |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Icon | Label gap | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|---|
| Compact | 36px | 4px | 15px | 2px | — | — | — | Inside a card header or a table header, where the strip must not out-weigh the content. |
| Default | 44px | 6px | 15px | 4px | — | — | — | Editors and page-level regions. |
| Touch | 52px | 4px | 18px | 4px | — | — | 44px per control | Coarse pointers. Controls grow to 44px; the strip scrolls horizontally rather than wrapping. |
| Divider | 20px | — | — | — | — | — | — | 1px wide, 8px margins. Shorter than the strip so it separates rather than encloses. |
| Overflow menu | — | — | — | — | 12rem | 18rem | — | Holds the collapsed controls in their original order, with their labels spelled out. |
role="toolbar" aria-orientation="horizontal"
first.tabIndex = 0 · rest.tabIndex = −1<button aria-label="Bold" aria-pressed="true">Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Idle glyphs must reach 4.5:1 — they are the label. A toolbar of 3:1 icons is unreadable for anyone in bright light.
- The pressed state changes fill and glyph colour together, so which controls are active survives greyscale.
- Dividers are decorative only if the grouping is also carried by role="separator". If the line is the only grouping signal, it owes 3:1.
Keyboard
| Tab | Enters the toolbar once, landing on the last-focused control, and leaves it once. |
| ← / → | Moves between controls, wrapping at both ends. This is what makes it a toolbar. |
| Home / End | Jumps to the first or last control. |
| Space / Enter | Activates or toggles the focused control. |
| ↓ | On the overflow trigger, opens the menu and focuses its first item. |
| Esc | Closes the overflow menu and returns focus to its trigger. |
Screen readers
- Announces as "Formatting, toolbar" then the focused control: "Bold, toggle button, pressed".
- A bulk-action bar should name its scope in the toolbar label — "Actions for 4 selected deployments" — so the count is heard before any action.
- When controls collapse into overflow, they must keep their full text labels in the menu. An icon-only overflow menu is unusable.
Focus & touch
- The toolbar remembers which control had focus and returns there on re-entry — resetting to the first control every time makes the pattern slower than plain tab stops. Disabled controls are skipped by the arrow keys but keep their position, so nothing moves when a control becomes available.
- Controls grow to 44px on coarse pointers and the strip scrolls horizontally rather than wrapping — a toolbar that wraps to three rows takes over the screen. Keep the first control flush to the edge and let the last one be partially cut off; that overflow cue is what tells a touch user the strip scrolls.
| Attribute | Applied to | Notes |
|---|---|---|
| role="toolbar" | The container | Promises arrow-key navigation. Do not use it unless roving tabindex is actually implemented. |
| aria-label | The container | Names what the strip acts on: "Formatting", "Actions for 4 selected deployments". |
| aria-orientation | The container | Horizontal by default. A vertical toolbar must say so, or arrow keys are announced wrongly. |
| aria-pressed | Toggle controls | For state. Command buttons — Undo, Export — must not have it. |
| role="separator" | Dividers | With aria-orientation="vertical". This is how the grouping reaches anyone not looking at the line. |
| aria-disabled | Temporarily unavailable controls | Preferred over the disabled attribute, so the control stays in the roving order and its position never shifts. |
Example usage
1// Roving tabindex is the whole pattern. Everything else is styling.2function Toolbar({ label, children }) {3 const ref = React.useRef(null)4 const items = () =>5 [...ref.current.querySelectorAll('[data-tb-item]:not(:disabled)')]67 const move = (dir) => {8 const all = items()9 const from = all.indexOf(document.activeElement)10 const next = (from + dir + all.length) % all.length11 all.forEach((el, i) => el.tabIndex = i === next ? 0 : -1)12 all[next].focus()13 }1415 return (16 <div17 ref={ref}18 role="toolbar"19 aria-label={label}20 aria-orientation="horizontal"21 onKeyDown={(e) => {22 if (e.key === 'ArrowRight') { e.preventDefault(); move(1) }23 if (e.key === 'ArrowLeft') { e.preventDefault(); move(-1) }24 }}25 >26 {children}27 </div>28 )29}3031// Overflow order is data, not a flexbox accident. Least-used collapses first.32const CONTROLS = [33 { id: 'bold', priority: 1 },34 { id: 'italic', priority: 1 },35 { id: 'quote', priority: 3 }, // first to go36]37const visible = CONTROLS.filter((c) => c.priority <= level)38const overflow = CONTROLS.filter((c) => c.priority > level)Framework-free HTML
<div role="toolbar" aria-label="Formatting" aria-orientation="horizontal">
<!-- Exactly one tabindex="0". Arrows move it. -->
<button type="button" aria-label="Undo" tabindex="0">…</button>
<button type="button" aria-label="Redo" tabindex="-1" aria-disabled="true">…</button>
<span role="separator" aria-orientation="vertical"></span>
<button type="button" aria-label="Bold" aria-pressed="true" tabindex="-1">…</button>
<button type="button" aria-label="Italic" aria-pressed="false" tabindex="-1">…</button>
<span role="separator" aria-orientation="vertical"></span>
<button
type="button"
aria-label="More formatting options"
aria-haspopup="menu"
aria-expanded="false"
tabindex="-1"
>…</button>
</div>CSS
[role='toolbar'] {
display: flex;
align-items: center;
gap: 4px; /* inside a group */
padding: 6px;
border: 1px solid var(--ds-border-subtle);
border-radius: var(--radius-lg);
background: var(--ds-surface);
}
[role='toolbar'] > [role='separator'] {
inline-size: 1px;
block-size: 20px; /* shorter than the strip: separates, not encloses */
margin-inline: 8px; /* double the intra-group gap — that ratio is the grouping */
background: var(--ds-border-subtle);
}
[role='toolbar'] button {
inline-size: 32px;
block-size: 32px;
border-radius: var(--radius-md);
color: var(--ds-fg-secondary); /* 4.5:1 — the glyph IS the label */
}
[role='toolbar'] button:hover { background: var(--ds-layer-hover); color: var(--ds-fg); }
[role='toolbar'] button[aria-pressed='true'] {
background: var(--ds-accent-subtle);
color: var(--ds-accent-text);
}
/* Scroll rather than wrap. A toolbar three rows tall has taken over the page. */
@media (pointer: coarse) {
[role='toolbar'] {
overflow-x: auto;
flex-wrap: nowrap;
scrollbar-width: none;
}
[role='toolbar'] button { inline-size: 44px; block-size: 44px; }
}Component API
Toolbar
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | string | — | Names what the strip acts on. Becomes aria-label. |
| orientation | 'horizontal' | 'vertical' | 'horizontal' | Decides which arrow keys move focus and what is announced. |
| children* | ReactNode | — | Controls marked data-tb-item, plus Divider elements between groups. |
| density | 'compact' | 'default' | 'default' | Reduces padding and gap. Never reduces the control below its touch minimum. |
Professional tips
- Show the keyboard shortcut in each control’s tooltip. A toolbar is used by people who repeat actions, and repetition is exactly who shortcuts are for.
- Keep the overflow trigger in a fixed final position at every width. It is the control people reach for when they cannot find something, so it must never be the thing that moved.
- A bulk-action bar should state its count first and clear its selection last, with the destructive action past a divider between them.
- Vertical toolbars are legitimate for canvas tools, but flip aria-orientation and the arrow keys together — one without the other is worse than neither.
Performance
- Query the toolbar’s items once per render, not per keystroke. A querySelectorAll in a keydown handler on a twenty-control strip is measurable during key repeat.
- Use a ResizeObserver on the strip to drive overflow, not a window resize listener — the toolbar can narrow when a side panel opens without the window changing at all.
- Do not mount the overflow menu until it opens. Twenty toolbars in a table each holding a hidden popover is twenty panels of layout nobody sees.
Common mistakes
- Using role="toolbar" without roving tabindex, promising arrow keys that do nothing.
- Resetting focus to the first control every time the toolbar is re-entered, which makes the pattern slower than plain tab stops.
- Removing a disabled control from the DOM, so everything after it shifts position while the user is aiming.
- Relying on tooltips as accessible names for icon-only controls.
- Letting flex-wrap decide the overflow, so controls reorder at every breakpoint.
- Placing Delete in the middle of the strip, directly on the path to something safe.
Real-world recommendations
- Editor toolbars are the pattern’s home ground, and the honest finding is that most users touch five controls. Instrument it, keep those five visible, and let the rest live in overflow permanently.
- Bulk-action bars work best when they replace the existing toolbar in place. Sliding a new bar in above the table shifts every row down at the exact moment the user is aiming at one.
- On mobile, a horizontally scrolling toolbar with the last control partially cut off is the clearest affordance. A wrapped toolbar reads as a broken layout.
- Anything used more than roughly once a session deserves a shortcut, and the toolbar is where users discover it. The tooltip is doing double duty as documentation.