Accessibility Developer Kit · Guide 03 / 20

Accessible Icon Button

Icon-only buttons without a visible label are an easy win — or an easy fail. This guide covers aria-label, visible tooltip pattern, toggle (aria-pressed), and toolbar groups with roving tabindex, in Vanilla and React.

  • WCAG: 1.1.1 · 2.5.5 · 4.1.2 · 1.4.11
  • Level: Beginner → Intermediate
  • Icons: lucide-react
1

What we are building

Icon-only buttons that keep their meaning for keyboard, screen reader, voice and touch users.

What we are building

An icon-only button is a <button> whose visible content is an SVG. Because there is no visible text, the button needs its accessible name supplied some other way — typically aria-label. We also cover the three follow-on patterns you will meet in production: tooltips, toggles and toolbars.

2

The 4 real-world variants

Switch between the inaccessible version and the accessible fix.

The 4 real-world variants

Variant A

Standard icon button

Preferred
Live example

Deleted 0 times

HTML · Accessible
<!-- aria-label on the button; svg hidden from AT -->
<button type="button" aria-label="Delete item">
  <svg aria-hidden="true" focusable="false">
    <!-- trash icon -->
  </svg>
</button>

aria-label gives the button its accessible name. The SVG becomes decorative with aria-hidden='true' and focusable='false' (for old IE).

Variant B

With visible tooltip

Common UI
Live example

Hover or Tab to focus — tooltip appears for both; Escape dismisses.

HTML · Accessible
<!-- Visible tooltip + aria-label fallback.
     Tooltip appears on hover AND focus (WCAG 1.4.13), dismissable with Escape. -->
<div class="tt-wrap">
  <button
    type="button"
    aria-label="Share this page"
    aria-describedby="tip-share"
  >
    <svg aria-hidden="true" focusable="false"><!-- share icon --></svg>
  </button>
  <div role="tooltip" id="tip-share" class="tooltip">
    Share this page
  </div>
</div>

title='...' is NOT a real tooltip (fails on touch, random delays). Build a visible one on hover/focus and keep aria-label as the SR fallback.

Variant C

Toggle (aria-pressed)

Preferred
Live example

Bookmark: not saved · Audio: on

HTML · Accessible
<!-- aria-pressed makes it a toggle; SR says "pressed" / "not pressed" -->
<button
  type="button"
  aria-label="Bookmark this article"
  aria-pressed="false"
  onclick="toggleBookmark()"
>
  <svg aria-hidden="true" focusable="false"><!-- bookmark --></svg>
</button>

<!-- Alternative: change the label itself with state -->
<button type="button" aria-label="Mute audio" onclick="toggleMute()">
  <svg aria-hidden="true" focusable="false"><!-- speaker icon --></svg>
</button>

aria-pressed turns a button into a two-state toggle (pressed / not pressed). Alternatively, change the aria-label itself with state.

Variant D

Toolbar group

Advanced
Live example

Tab into the toolbar, then use ← → (and Home / End) to move between buttons.

HTML · Accessible
<!-- role="toolbar" + unique labels + roving tabindex -->
<div role="toolbar" aria-label="Text formatting">
  <button type="button" aria-label="Bold" aria-pressed="false" tabindex="0">
    <svg aria-hidden="true" focusable="false"><!-- bold --></svg>
  </button>
  <button type="button" aria-label="Italic" aria-pressed="false" tabindex="-1">
    <svg aria-hidden="true" focusable="false"><!-- italic --></svg>
  </button>
  <button type="button" aria-label="Underline" aria-pressed="false" tabindex="-1">
    <svg aria-hidden="true" focusable="false"><!-- underline --></svg>
  </button>
</div>

<script>
  // Arrow keys move between buttons; only the active one is tabbable.
</script>

role='toolbar' + unique labels + roving tabindex: Tab enters, arrow keys move between buttons. Prevents the user from Tab-ing through 15 buttons.

3

Flagship implementation

A reusable icon button that handles standard, toggle and tooltip cases.

Flagship implementation

AccessibleIconButton.jsx
import React from "react";
import "./AccessibleIconButton.css";

/**
 * AccessibleIconButton
 *   - icon:      a lucide-react icon component (required)
 *   - label:     accessible name (required)
 *   - pressed:   boolean | undefined — makes it a toggle
 *   - describedBy: id of a visible tooltip (optional)
 *   - onClick, disabled, ...rest
 */
export default function AccessibleIconButton({
  icon: Icon,
  label,
  pressed,
  describedBy,
  onClick,
  disabled = false,
  className = "",
  ...rest
}) {
  if (process.env.NODE_ENV !== "production" && (!label || !Icon)) {
    console.warn("AccessibleIconButton requires both 'icon' and 'label'.");
  }

  return (
    <button
      type="button"
      className={`icon-btn ${className}`}
      aria-label={label}
      aria-pressed={pressed}
      aria-describedby={describedBy}
      disabled={disabled}
      onClick={onClick}
      {...rest}
    >
      <Icon aria-hidden="true" focusable="false" size={16} />
    </button>
  );
}
Usage · Demo.jsx
import { useState } from "react";
import { Trash2, Bookmark, Share2 } from "lucide-react";
import AccessibleIconButton from "./AccessibleIconButton";

export default function Demo() {
  const [bookmarked, setBookmarked] = useState(false);

  return (
    <>
      {/* Standard icon button */}
      <AccessibleIconButton
        icon={Trash2}
        label="Delete item"
        onClick={handleDelete}
      />

      {/* Toggle with aria-pressed */}
      <AccessibleIconButton
        icon={Bookmark}
        label="Bookmark this article"
        pressed={bookmarked}
        onClick={() => setBookmarked((b) => !b)}
      />

      {/* With a tooltip (describedBy references a visible tooltip id) */}
      <div className="tt-wrap">
        <AccessibleIconButton
          icon={Share2}
          label="Share this page"
          describedBy="tt-share"
        />
        <div role="tooltip" id="tt-share">Share this page</div>
      </div>
    </>
  );
}
4

Keyboard behavior

Icon buttons follow regular button rules. Toolbars add arrow-key support.

Keyboard behavior

KeyExpected result
TabIcon button receives visible focus (or enters toolbar)
Enter / SpaceActivates the button
EscDismisses any visible tooltip
← → (toolbar)Moves between buttons inside role="toolbar"
Home / End (toolbar)Jumps to first / last button in the toolbar
5

Screen reader expectations

Exact wording varies by browser + SR combination.

Screen reader expectations

  • Standard icon button: “Delete item, button”
  • With tooltip: “Share this page, button, Share this page” (label + described-by text)
  • Toggle (not pressed): “Bookmark this article, toggle button, not pressed”
  • Toggle (pressed): “Bookmark this article, toggle button, pressed”
  • Toolbar entry: “Text formatting toolbar, Bold toggle button, not pressed”
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
1.1.1 Non-text Content (A)Icon needs a text alternative — aria-label or sr-only text.
2.5.5 Target Size (AA, WCAG 2.2)Icon buttons must be at least 24×24 px (prefer 44×44).
4.1.2 Name, Role, Value (A)Must expose role=button, an accessible name, and aria-pressed state when applicable.
1.4.11 Non-text Contrast (AA)The icon stroke and focus ring must have 3:1 contrast against the background.
1.4.13 Content on Hover or Focus (AA)Tooltips must be dismissable, hoverable and persistent.
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

Pattern: render → axe check → assert accessible name & state. For toolbars, add a keyboard-navigation assertion.

Jest + Testing Library + jest-axe

AccessibleIconButton.test.jsx
// Jest + @testing-library/react + jest-axe
import { render, screen } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { Trash2 } from "lucide-react";
import AccessibleIconButton from "./AccessibleIconButton";

expect.extend(toHaveNoViolations);

test("exposes the aria-label as the accessible name", () => {
  render(<AccessibleIconButton icon={Trash2} label="Delete item" />);
  expect(
    screen.getByRole("button", { name: "Delete item" })
  ).toBeInTheDocument();
});

test("toggle button reports aria-pressed state", () => {
  render(
    <AccessibleIconButton icon={Trash2} label="Bookmark" pressed />
  );
  expect(screen.getByRole("button")).toHaveAttribute("aria-pressed", "true");
});

test("has no axe violations", async () => {
  const { container } = render(
    <AccessibleIconButton icon={Trash2} label="Delete" />
  );
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleIconButton.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import { Trash2 } from "lucide-react";
import AccessibleIconButton from "./AccessibleIconButton";

test("renders with the given accessible name", () => {
  render(<AccessibleIconButton icon={Trash2} label="Delete item" />);
  expect(screen.getByRole("button", { name: "Delete item" })).toBeInTheDocument();
});

Playwright + @axe-core/playwright

icon-button.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("icon-button page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-icon-button");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("toolbar supports arrow key navigation", async ({ page }) => {
  await page.goto("/en/accessible-icon-button");
  const toolbar = page.getByRole("toolbar", { name: /text formatting/i });
  await toolbar.getByRole("button").first().focus();
  await page.keyboard.press("ArrowRight");
  await expect(
    page.getByRole("button", { name: "Italic" })
  ).toBeFocused();
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Every icon button has an accessible name (aria-label or sr-only text)
  • SVG inside has aria-hidden="true" and focusable="false"
  • Target size at least 24×24 px (prefer 44×44)
  • Focus ring visible and 3:1 contrast with background
  • Icon stroke 3:1 contrast with background
  • Toggle uses aria-pressed OR flips the aria-label
  • Tooltip appears on hover AND focus, dismissable with Escape
  • Toolbar uses role="toolbar" with unique labels and roving tabindex
  • No two buttons on the page share the same accessible name
  • Zero critical / serious axe violations
9

Common mistakes

The icon button bugs we see on almost every audit.

Common mistakes

  • No aria-label.SR says “button” with no name — the user has no idea what it does.
  • SVG not aria-hidden.Screen readers may try to announce the SVG’s own <title> or path IDs, producing garbage.
  • title="..." as the only label. Tooltips vary by OS, fail on touch, and some SRs skip them.
  • Toggle without aria-pressed.SR users don’t hear the state; sighted users rely on color only.
  • Duplicate labels.Three “Delete” buttons? Add context: “Delete user alice@example.com”.
  • Tap target too small. 32×32 is below the WCAG 2.2 recommendation.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo