# Toolbars of clickable icons a keyboard can't reach

Source: https://easeweb.dev/learn/toolbars
Topics: Complex widgets > Toolbars work with arrow keys and take one Tab stop (WCAG 2.1.1, 4.1.2)
The fix: Make each tool a native button named for what it does. Either leave every button in the Tab order, or group them in role="toolbar" with a name, one Tab stop and the arrow keys to move between them, as the ARIA toolbar pattern describes.
Test with: keyboard, screen reader, speech input, forced colors, zoom
References: https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/ https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/examples/toolbar/ https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F15 https://www.w3.org/WAI/WCAG22/Techniques/failures/F59 https://www.w3.org/TR/wai-aria-1.2/#toolbar

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A marketplace lets sellers fix a listing photo before it goes live. Under the photo sits a row of six icons: Rotate left, Rotate right, Flip, Zoom in, Zoom out and Reset. A seller whose photo came out sideways clicks Rotate right, and the mug stands upright. Each icon is a span holding an SVG, with a click handler on the span. Put the mouse aside and press Tab: focus goes from the page above straight to Save photo, past all six tools. No key turns the photo, so a keyboard user can only save it sideways. A screen reader reading the toolbar finds six spans with no text, no role and no name: nothing says the tools are there, that they can be pressed, or what each one does.

## Why it fails

Success criterion 2.1.1 Keyboard asks that everything the mouse can do, the keyboard can do. Every tool works only on a click, on a span, which takes no focus. The browser turns Enter and Space into a click only on a native control, so even a span given `tabindex` would still ignore both keys. Turning, flipping and zooming the photo can't be done from the keyboard at all.

Success criterion 4.1.2 Name, Role, Value asks that every control's name and role can be determined by software. The tools are controls: a click on each one changes the photo. A span has no role of its own, so nothing says it can be pressed, and the icon inside is an SVG hidden from screen readers, so nothing gives the span a name either. W3C documents this as failure F59, script that makes a span or div a control without giving it a role, and more broadly as failure F15, a custom control that doesn't use the accessibility API.

## Who is affected

Toolbars like this sit wherever a site edits something in place: photo and avatar croppers, document and PDF viewers, comment boxes, file lists with actions for what is selected. Icon tools are often drawn small and close together, which makes them hard to click for people with tremors, and some of them turn to the keyboard for exactly that reason.

## What automation and AI miss

axe has nothing to check in this toolbar. A span with a click handler has no role, so no rule asks it for a name, and nothing in the markup says it is meant to be pressed. The same span after the fix an AI may suggest, `role="button"`, `tabindex="0"` and an `aria-label`, passes axe and Tab reaches it, but Enter and Space still do nothing, as [div buttons that can't be reached with Tab](/learn/native-buttons) shows.

The audit presses Tab through the page. An element with its own pointer cursor and an icon or words inside, in a container named as a toolbar by its class, id or role, that Tab never reaches is reported, when two or more such tools sit side by side and nothing in the row takes focus. In each `role="toolbar"` whose tools share one Tab stop, it focuses that tool and presses the arrow keys, Home and End; if focus never moves, the other tools can't be reached, and that is reported too. That is the other half-finished fix: the markup of the toolbar pattern, `tabindex="-1"` on every tool but the first, copied without the script that moves focus. A toolbar of clickable icons whose container has no telling class name, id or role is not reported as a toolbar, and no check can tell whether a name says what the icon does.

## Before: icons in spans with click handlers

```html
<div class="photo-editor">
  <img class="photo" src="/uploads/mug.jpg" alt="Enamel mug, front view">
  <div class="toolbar">
    <span class="tool" data-action="rotate-left">
      <svg aria-hidden="true">…</svg>
    </span>
    <span class="tool" data-action="rotate-right">
      <svg aria-hidden="true">…</svg>
    </span>
    <span class="tool" data-action="flip">
      <svg aria-hidden="true">…</svg>
    </span>
    <span class="tool" data-action="zoom-in">
      <svg aria-hidden="true">…</svg>
    </span>
    <span class="tool" data-action="zoom-out">
      <svg aria-hidden="true">…</svg>
    </span>
    <span class="tool" data-action="reset">
      <svg aria-hidden="true">…</svg>
    </span>
  </div>
  <button type="button">Save photo</button>
</div>
```

```css
.toolbar { display: flex; gap: 4px; }
.tool { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 8px; cursor: pointer; }
.tool:hover { background: #eef0f3; }
.tool svg { width: 20px; height: 20px; }
```

```js
const photo = document.querySelector('.photo');
const view = { turn: 0, flip: 1, zoom: 1 };
const ACTIONS = {
  'rotate-left': () => { view.turn -= 90; },
  'rotate-right': () => { view.turn += 90; },
  'flip': () => { view.flip *= -1; },
  'zoom-in': () => { view.zoom = Math.min(view.zoom + 0.25, 2); },
  'zoom-out': () => { view.zoom = Math.max(view.zoom - 0.25, 1); },
  'reset': () => { Object.assign(view, { turn: 0, flip: 1, zoom: 1 }); }
};
for (const tool of document.querySelectorAll('.tool')) {
  tool.addEventListener('click', () => {
    ACTIONS[tool.dataset.action]();
    photo.style.transform = `rotate(${view.turn}deg) scale(${view.flip * view.zoom}, ${view.zoom})`;
  });
}
```

- The tools respond only to `click`, on spans that take no focus, so no key reaches or presses them.
- A span has no role, so no tool has one.
- Each span's only content is an icon hidden from screen readers, so no tool has a name.

## After: a button for each tool

```html
<div class="photo-editor">
  <img class="photo" src="/uploads/mug.jpg" alt="Enamel mug, front view">
  <div class="toolbar">
    <button type="button" class="tool" aria-label="Rotate left" data-action="rotate-left">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="Rotate right" data-action="rotate-right">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="Flip" data-action="flip">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="Zoom in" data-action="zoom-in">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="Zoom out" data-action="zoom-out">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="Reset" data-action="reset">
      <svg aria-hidden="true">…</svg>
    </button>
  </div>
  <button type="button">Save photo</button>
</div>
```

```css
.toolbar { display: flex; gap: 4px; }
.tool { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 8px; cursor: pointer; }
.tool:hover { background: #eef0f3; }
.tool svg { width: 20px; height: 20px; }
/* A button's own border, background and font, reset to the icon's look. */
.tool { border: 0; background: none; color: inherit; font: inherit; }
.tool:focus-visible { outline: 2px solid; outline-offset: 2px; }
```

```js
const photo = document.querySelector('.photo');
const view = { turn: 0, flip: 1, zoom: 1 };
const ACTIONS = {
  'rotate-left': () => { view.turn -= 90; },
  'rotate-right': () => { view.turn += 90; },
  'flip': () => { view.flip *= -1; },
  'zoom-in': () => { view.zoom = Math.min(view.zoom + 0.25, 2); },
  'zoom-out': () => { view.zoom = Math.max(view.zoom - 0.25, 1); },
  'reset': () => { Object.assign(view, { turn: 0, flip: 1, zoom: 1 }); }
};
for (const tool of document.querySelectorAll('.tool')) {
  tool.addEventListener('click', () => {
    ACTIONS[tool.dataset.action]();
    photo.style.transform = `rotate(${view.turn}deg) scale(${view.flip * view.zoom}, ${view.zoom})`;
  });
}
```

1. Make each tool a native button. Tab reaches it, Enter and Space press it, and the browser turns both into the same `click` the script already listens for: the script doesn't change.
2. Name each button for what it does with `aria-label`, so a screen reader says "Rotate right, button", and voice control users can say "Click Rotate right".
3. In the CSS, reset the button's own look to the icon's, and add a focus ring.

This fixes the Before and passes 2.1.1 and 4.1.2. Six tools are six Tab stops between the photo and Save photo.

## After: a toolbar with one Tab stop and the arrow keys

```html
<div class="photo-editor">
  <img class="photo" src="/uploads/mug.jpg" alt="Enamel mug, front view">
  <div class="toolbar" role="toolbar" aria-label="Photo tools">
    <button type="button" class="tool" aria-label="Rotate left" data-action="rotate-left" tabindex="0">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="Rotate right" data-action="rotate-right" tabindex="-1">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="Flip" data-action="flip" tabindex="-1">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="Zoom in" data-action="zoom-in" tabindex="-1">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="Zoom out" data-action="zoom-out" tabindex="-1">
      <svg aria-hidden="true">…</svg>
    </button>
    <button type="button" class="tool" aria-label="Reset" data-action="reset" tabindex="-1">
      <svg aria-hidden="true">…</svg>
    </button>
  </div>
  <button type="button">Save photo</button>
</div>
```

```css
.toolbar { display: flex; gap: 4px; }
.tool { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 8px; cursor: pointer; }
.tool:hover { background: #eef0f3; }
.tool svg { width: 20px; height: 20px; }
/* A button's own border, background and font, reset to the icon's look. */
.tool { border: 0; background: none; color: inherit; font: inherit; }
.tool:focus-visible { outline: 2px solid; outline-offset: 2px; }
```

```js
const photo = document.querySelector('.photo');
const view = { turn: 0, flip: 1, zoom: 1 };
const ACTIONS = {
  'rotate-left': () => { view.turn -= 90; },
  'rotate-right': () => { view.turn += 90; },
  'flip': () => { view.flip *= -1; },
  'zoom-in': () => { view.zoom = Math.min(view.zoom + 0.25, 2); },
  'zoom-out': () => { view.zoom = Math.max(view.zoom - 0.25, 1); },
  'reset': () => { Object.assign(view, { turn: 0, flip: 1, zoom: 1 }); }
};
for (const tool of document.querySelectorAll('.tool')) {
  tool.addEventListener('click', () => {
    ACTIONS[tool.dataset.action]();
    photo.style.transform = `rotate(${view.turn}deg) scale(${view.flip * view.zoom}, ${view.zoom})`;
  });
}
const toolbar = document.querySelector('[role="toolbar"]');
const tools = [...toolbar.querySelectorAll('.tool')];
// One Tab stop: the tool that last had focus, however it got there.
toolbar.addEventListener('focusin', (event) => {
  for (const tool of tools) tool.tabIndex = tool === event.target ? 0 : -1;
});
toolbar.addEventListener('keydown', (event) => {
  const i = tools.indexOf(event.target);
  const to = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tools.length - 1 }[event.key];
  if (i < 0 || to === undefined) return;
  event.preventDefault();
  // Right from the last tool goes round to the first, and Left from the first to the last.
  tools.at(to % tools.length).focus();
});
```

1. Use the same named buttons, and group them in `role="toolbar"` with a name, so a screen reader says "Photo tools, toolbar" on the way in.
2. Make the whole toolbar one Tab stop: `tabindex="0"` on the first tool and `-1` on the rest. Tab leaves the toolbar in one press.
3. Handle the keys of the ARIA Authoring Practices toolbar pattern: Right and Left move to the next and previous tool and go round at the ends, Home and End jump to the first and last. Enter and Space still press a tool, natively.
4. Hand `tabindex="0"` to whichever tool takes focus, so Shift + Tab back into the toolbar lands on the tool that last had focus. That works by key, and by click in browsers that focus a clicked button (Safari does not).

This fixes the Before and passes 2.1.1 and 4.1.2.

## Which option to use

Both pass. Buttons left in the Tab order are the smaller change: no ARIA role, no key handling, and every keyboard user already knows how to move through them. Neither WCAG nor ARIA asks for one Tab stop, and a row of six buttons is six presses of Tab, which is fine for an editor used now and then. The toolbar saves those presses: one Tab past the whole row, and it tells screen reader users the tools belong together. Choose it when the tools are many or used often, such as a document editor with a dozen tools, or several toolbars in a row that keyboard users would otherwise tab through on every visit. Then it needs all of it: the role, a name, one Tab stop and the arrow keys. The ARIA Authoring Practices advise the toolbar role only for a group of three or more controls.

## Implementation decisions

Name each tool for what it does, in the words a tooltip or a help page would use: [icon-only buttons with no accessible name](/learn/icon-buttons) covers the naming in detail. Sighted users who don't know an icon need the name too; a tooltip that shows it on hover and on focus, with the same words, helps both. A tool that stays on, such as Crop or Bold, is a toggle and needs `aria-pressed`, as [toggle buttons that don't say whether they are pressed](/learn/toggle-buttons) explains.

A tool that can't be used right now, such as Zoom out when the photo is back at its original size, can be disabled. The toolbar pattern notes that disabled tools are usually skipped by the arrow keys, but may be kept focusable when people need to find them; `aria-disabled="true"` does that, as [disabled buttons that can't be found or say why](/learn/disabled-controls) describes. A toolbar that runs down the side of the screen takes `aria-orientation="vertical"`, and the Down and Up arrows move between its tools instead of Right and Left.

Keep a toolbar to buttons, or controls that don't use Right and Left themselves. A text field or a slider inside it needs those keys, so the pattern advises against them, and where one can't be avoided, allows only one, as the last control in the toolbar. On a narrow screen, tools that don't fit often move into a More button: that is a menu, with its own keys, covered in [action menus that open silently, out of keyboard reach](/learn/menu-button). Toolbar components in libraries vary: check that the one you use makes every tool a button, names it and handles the keys, before relying on it.

## Verify the fix

1. Put the mouse aside and Tab from the top of the page. Reach every tool and use it with Enter and with Space, then reach Save photo. With the toolbar, check that one Tab enters it, the arrow keys, Home and End move between the tools, one Tab leaves it, and Shift + Tab returns to the tool last used.
2. With a screen reader, move to each tool: it is announced as a button with what it does, such as "Rotate right, button". With the toolbar, the screen reader also says "Photo tools, toolbar" as focus enters it.
3. With voice control, say "Click Rotate right" and "Click Zoom in": each tool responds to its name.
4. Turn on forced colors (Windows Contrast themes) and zoom to 200%. Every icon and the focus ring on each tool are still visible, and no tool is cut off.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the toolbar, its component or its library changes.

## Limits

This is a starting pattern, not a guarantee for every application. The toolbar script is a minimal sketch: it has no disabled tools, no vertical orientation and no controls other than buttons. The typing area of an editor, and its name, are covered in [rich text editors announced without their label](/learn/rich-text-editors), and a single control built from a div in [div buttons that can't be reached with Tab](/learn/native-buttons). The same one-Tab-stop pattern, for a sidebar, is shown in [tree views keyboards can't open and screen readers flatten](/learn/tree-views). Test the complete journey with your target assistive technology.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
