Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

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.

The bar replaces the normal toolbar in place rather than appearing above it, so the table below never shifts down by 44px at the moment the user is aiming at a row.

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.

Grouped
Ungrouped

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.

Default
Pressed
Disabled
Focus
Divider
Overflow
Text control
Danger

Anatomy

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.

  1. 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.

  2. 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.

  3. Gap4px inside a group

    Tight enough that a group reads as one unit, wide enough that two adjacent focus rings never touch.

  4. 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.

  5. 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.

  6. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--space-1Gap inside a group
--space-2Divider margins — double the intra-group gap

Radius

TokenValueUsed for
--radius-mdControl corners
--radius-lgStrip corners

Motion

TokenValueUsed for
--duration-fastHover and press transitions

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingIconLabel gapMin widthMax widthTouch targetWhen to use
Compact36px4px15px2px———Inside a card header or a table header, where the strip must not out-weigh the content.
Default44px6px15px4px———Editors and page-level regions.
Touch52px4px18px4px——44px per controlCoarse pointers. Controls grow to 44px; the strip scrolls horizontally rather than wrapping.
Divider20px——————1px wide, 8px margins. Shorter than the strip so it separates rather than encloses.
Overflow menu————12rem18rem—Holds the collapsed controls in their original order, with their labels spelled out.

Do

role="toolbar" aria-orientation="horizontal"
first.tabIndex = 0 · rest.tabIndex = −1
Give the strip one tab stopThis is the whole pattern. Roving tabindex means Tab crosses twelve controls in one press; without it a keyboard user pays twelve presses every time they pass the toolbar.
Group with dividers, and use role="separator"The line does the work for sighted users; the role does it for everyone else. Four groups of three is four decisions, and twelve loose icons is a wall.
Decide the overflow order before you build itControls must collapse into "⋯" in a published order, least-used first. A flex-wrap accident rearranges the strip on every resize and destroys spatial memory.
<button aria-label="Bold" aria-pressed="true">
Name every icon-only controlA tooltip is a supplement to an accessible name, not a substitute. It never appears for a screen reader and it never appears on touch.

Don't

Do not make every control a tab stopThen it is a row of buttons that announces itself as a toolbar — the worst combination, because the user is told to expect arrow keys and does not get them.
Do not put a destructive action mid-stripThe pointer travels across the whole strip. Delete belongs at the far end past a divider, where nothing safe sits beyond it to travel towards.
Do not let controls reorder on resizeA toolbar is used by muscle memory. If Bold is third at 1440px and fifth at 1100px, that memory is worthless and every action becomes a visual search.
Visible
All hidden
Do not hide the only copy of an action in overflowAnything used more than occasionally must be visible at the default width. Overflow is for the tail, not for whatever happened not to fit.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.3Focus OrderA2.5.8Target Size (Minimum)AA4.1.2Name, Role, ValueA

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

TabEnters 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 / EndJumps to the first or last control.
Space / EnterActivates or toggles the focused control.
↓On the overflow trigger, opens the menu and focuses its first item.
EscCloses 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.
AttributeApplied toNotes
role="toolbar"The containerPromises arrow-key navigation. Do not use it unless roving tabindex is actually implemented.
aria-labelThe containerNames what the strip acts on: "Formatting", "Actions for 4 selected deployments".
aria-orientationThe containerHorizontal by default. A vertical toolbar must say so, or arrow keys are announced wrongly.
aria-pressedToggle controlsFor state. Command buttons — Undo, Export — must not have it.
role="separator"DividersWith aria-orientation="vertical". This is how the grouping reaches anyone not looking at the line.
aria-disabledTemporarily unavailable controlsPreferred over the disabled attribute, so the control stays in the roving order and its position never shifts.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.

Notes

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.