web3icons

Icon-only buttons need names

How to name copy, download, close and settings buttons that show only an icon, why tooltips are not labels, and how WCAG 2.5.3 Label in Name applies.

6 min read

Crypto interfaces are full of buttons that are nothing but an icon: copy an address, download a logo, close a modal, open settings, swap the direction of a trade. To a sighted mouse user they're obvious. To a screen reader, a button with only an SVG inside has no name, so it is announced as "button" and nothing else. If the icon is an <img> without alt text, some screen readers read out its file name instead, something like "copy-icon-final", which is arguably worse.

Every button needs an accessible name under 4.1.2 Name, Role, Value (A), and an icon that carries meaning needs a text alternative under 1.1.1 Non-text Content (A). Meeting both takes one line of markup. Choosing the right words and handling tooltips and states takes a bit more thought.

Three ways to give a button a name

<!-- 1. aria-label on the button -->
<button type="button" aria-label="Copy address">
  <svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20">…</svg>
</button>

<!-- 2. Visually hidden text inside the button -->
<button type="button">
  <svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20">…</svg>
  <span class="sr-only">Copy address</span>
</button>

<!-- 3. An image with alt text -->
<button type="button">
  <img src="/icons/copy.svg" alt="Copy address" width="20" height="20" />
</button>

All three produce a button named "Copy address". In the first two, aria-hidden="true" on the SVG keeps screen readers from trying to describe the drawing, which decorative or informative icons explains in more depth.

Approach Good Watch out for
aria-label one attribute; no extra elements some automatic translation tools skip attribute values, so the name can stay untranslated
Visually hidden text real text content that translation tools and screen readers handle well needs a working sr-only class
<img> with alt natural when the icon is already an image file the alt text must describe the action, not the picture

Either of the first two is a sound default. Pick one and use it consistently across your codebase. The visually hidden class has to hide text visually while keeping it in the accessibility tree, which display: none and visibility: hidden don't do:

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Name the action, not the picture

The name should say what happens when you press the button, in the words a person would use. Voice control users activate buttons by saying their names, so they'll say what they think the button does. A screen reader user hears the name without seeing the icon at all.

Icon Avoid Use
Two overlapping squares "Clipboard", "copy-icon" Copy address
Arrow pointing into a tray "Arrow", "download-svg" Download SVG
× "X", "Times" Close
Gear "Gear", "Cog" Settings
Vertical arrows between swap fields "Arrows" Switch tokens
Square with an outward arrow "External link" View on block explorer
Three dots "Dots" More options

When the same button repeats down a list, add what it acts on. Ten buttons all named "Copy" in a token list are impossible to tell apart when someone pulls up a list of controls. In React, build the name from the row's data:

<button type="button" aria-label={`Copy ${token.symbol} contract address`} onClick={() => copy(token.address)}>
  <CopyIcon aria-hidden="true" />
</button>

This also helps with 2.4.6 Headings and Labels (AA), which asks that labels describe their purpose.

Tooltips are not labels

The title attribute looks like an easy fix, and it isn't one. Browsers show it only after a mouse hover delay, usually not on keyboard focus, and generally not on touch screens. Screen readers handle it inconsistently: some read it as the name when nothing else is available, some as an extra description, some not at all in certain modes. Use a real name and treat any tooltip as an extra.

A custom tooltip that shows the name visually is still worth having, since sighted users can't always guess what an icon means. To be useful to everyone, it has to appear on keyboard focus as well as hover, and it must meet 1.4.13 Content on Hover or Focus (AA): Escape dismisses it, the pointer can move onto it without it vanishing, and it stays until the user moves away.

If the tooltip shows the same words as the button's name, avoid announcing them twice. Either make the tooltip the label, with aria-labelledby pointing at it, or keep aria-label on the button and don't connect the tooltip at all. Wiring an identical tooltip through aria-describedby can make screen readers say "Copy address, button, Copy address".

Label in Name, when there is visible text

2.5.3 Label in Name (A) applies when a control has a visible text label: its accessible name has to contain that visible text. It matters most for voice control users, who say what they see.

Icon-only buttons have no visible text label, so the criterion mostly bites when someone adds an aria-label to a button that does show text. A button that reads "Download SVG" with aria-label="Download Ethereum logo" fails, because the name doesn't contain "Download SVG". Saying "click Download SVG" may not work. Keep the visible words and add context after them instead:

<button type="button">
  <svg aria-hidden="true" viewBox="0 0 24 24" width="20" height="20">…</svg>
  Download SVG<span class="sr-only">, Ethereum logo</span>
</button>

The name is now "Download SVG, Ethereum logo", which contains the visible label and tells screen reader users which logo they're getting. For icon-only buttons with a visible tooltip, the same reasoning suggests making the name match the tooltip text exactly.

Toggles and confirmations

A toggle needs a name and a state, and the two shouldn't contradict each other. For a "hide balances" eye icon, either keep the name fixed and expose the state, or change the name and leave the state out:

<!-- Fixed name, state in aria-pressed -->
<button type="button" aria-label="Hide balances" aria-pressed="true">…</button>

<!-- Or a name that changes with the state, without aria-pressed -->
<button type="button" aria-label="Show balances">…</button>

Combining both leads to announcements like "Show balances, toggle button, pressed", which can mean either state.

Confirmations are a separate problem. When a copy button swaps its icon for a check mark, that change is purely visual. Changing the button's name to "Copied" while it has focus is announced inconsistently, so put the confirmation in a polite role="status" region that's already on the page.

Build it into the component

The most reliable fix is a shared component that can't be used without a name:

import type { ReactNode } from 'react'

type IconButtonProps = {
  label: string
  icon: ReactNode
  onClick: () => void
}

export function IconButton({ label, icon, onClick }: IconButtonProps) {
  return (
    <button type="button" className="icon-button" aria-label={label} onClick={onClick}>
      <span aria-hidden="true">{icon}</span>
    </button>
  )
}

Because label is required, TypeScript rejects an unnamed button at build time, and wrapping the icon in an aria-hidden span means callers can't forget to hide it. Give .icon-button a minimum size too: 2.5.8 Target Size (Minimum) (AA) asks for at least 24 by 24 CSS pixels unless an exception applies, and 44 by 44, the AAA level in 2.5.5 Target Size (Enhanced), is more comfortable on touch screens. Target size covers the exceptions, and accessible SVG markup covers the cases where an SVG itself needs a name.

More in Accessibility

All guides