Accessible icon markup comes down to one question: if the icon disappeared, would a screen reader user lose any information?
If not, hide it. If so, give the information a text equivalent, and put that text on the thing the user interacts with. Most icon accessibility bugs come from answering the question wrong, or from a library default quietly overriding the markup you wrote.
The four situations you will meet
- Decorative, next to text. A trash can beside the word “Delete”. The icon repeats the label. Hide it.
- Informative, standing alone. A shield next to a username that means “verified”, with no text saying so. Give the graphic a name.
- The only content of a control. A close button that contains nothing but an ×. Name the button, not the icon.
- Carrying state. A chevron that flips when a panel opens, or a filled versus outline star. The state belongs in an ARIA attribute on the control; the icon stays hidden.
Almost every icon on a typical page falls into case 1 or case 3. Case 2 is rarer than people assume, because designers usually put a text label or tooltip next to meaningful icons anyway.
First, check what your icon library already does
Icon packages differ in their defaults, and some of them conflict with the markup you might add. I rendered one icon from each package in Icony's codebase with react-dom/server:
<!-- Heroicons 2.2.0 <CheckIcon title="Done" titleId="t1" /> -->
<svg … aria-hidden="true" aria-labelledby="t1"><title id="t1">Done</title>…</svg>
<!-- still hidden: aria-hidden wins, the title is never announced -->
<!-- Tabler 3.36.1 <IconCheck title="Done" /> -->
<svg … class="tabler-icon tabler-icon-check"><title>Done</title>…</svg>
<!-- Phosphor 2.1.10 <Check alt="Done" /> -->
<svg … fill="currentColor" viewBox="0 0 256 256"><title>Done</title>…</svg>
<!-- lucide-react 0.460 <Check /> : no aria attributes at all -->
<svg … class="lucide lucide-check"><path d="M20 6 9 17l-5-5"></path></svg>- Heroicons sets
aria-hidden="true"on every icon, which is a good default for decoration. But passingtitledoesn't remove it, so the<title>you added is never announced. To make a Heroicon informative, overridearia-hiddenyourself. - Tabler (
title) and Phosphor (alt) turn their prop into a<title>element but add norole. Chromium still exposes both as a named image, but support for SVG withoutrole="img"has historically been inconsistent across browsers and screen readers. Add the role when the icon is informative. - Lucide 0.460 adds nothing. Whether its icons are exposed or hidden depends on what you pass, so a decorative Lucide icon needs
aria-hidden="true"from you.
The Lucide output above is from lucide-react 0.460.0, the version Icony ships.
To see what reaches the accessibility tree, I put each library's × icon alone in a <button>, loaded the page in headless Chromium, and printed Playwright's ariaSnapshot() for each button:
# Heroicons: <button><XMarkIcon title="Close" titleId="close-t" /></button>
- button
# Heroicons, aria-hidden overridden: <XMarkIcon title="Close" titleId="close-t2" aria-hidden={undefined} />
- button "Close":
- img "Close"
# Tabler: <button><IconX title="Close" /></button>
- button "Close":
- img "Close"
# Phosphor: <button><X alt="Close" /></button>
- button "Close":
- img "Close"
# Lucide: <button><X /></button>
- button:
- img
# Lucide, named on the button: <button aria-label="Close"><X aria-hidden="true" /></button>
- button "Close"The Heroicons button has no name at all, even though a <title> is in the markup. Overriding aria-hidden fixes it. The bare Lucide icon shows up as an unnamed image inside an unnamed button, the worst of both.
Library defaults change between versions. Before trusting any of the above for your project, render the icon and inspect the DOM output. It takes a minute and tells you how the version you actually ship behaves.
Decorative icons: hide them
When the text already says what the icon shows, the icon is noise to a screen reader. Depending on the browser and the SVG's contents, an unhidden icon may be announced as “image”, “group”, or not at all. None of that helps. aria-hidden="true" removes it from the accessibility tree:
<!-- The word "Delete" already says everything -->
<button type="button">
<svg aria-hidden="true" …>…</svg>
Delete
</button>
// React: same idea with a component library
<button type="button">
<Trash2 aria-hidden="true" />
Delete
</button>Don't put aria-hidden on a parent that contains the text, or you hide the label along with the icon.
And don't hide an icon that is the only content of a link or button unless you give that control its own name. Otherwise you end up with a control that has no name, which is worse than an unlabeled icon.
Informative icons: give them a name
When the icon carries meaning that no nearby text provides, expose it as an image with a name. role="img" tells assistive technology to treat the whole SVG as one graphic instead of a group of shapes, and the name comes from either aria-label or a <title> referenced with aria-labelledby:
<!-- Option A: aria-label (simplest, no tooltip) -->
<svg role="img" aria-label="Verified account" viewBox="0 0 24 24" …>
<path d="…" />
</svg>
<!-- Option B: <title> referenced by id (shows a native tooltip on hover) -->
<svg role="img" aria-labelledby="verified-t" viewBox="0 0 24 24" …>
<title id="verified-t">Verified account</title>
<path d="…" />
</svg>
// Heroicons: its hard-coded aria-hidden must be removed explicitly
<CheckBadgeIcon role="img" title="Verified account" titleId="verified-t"
aria-hidden={undefined} />Pick one naming method. Option A is simpler and doesn't show a tooltip.
Option B shows the title as a browser tooltip when a mouse user hovers, which can be useful. That tooltip never appears for keyboard or touch users, though, so don't rely on it as the only visible explanation.
Write names that describe the meaning, not the drawing: “Verified account”, not “blue shield with checkmark”.
Icon-only buttons and links: name the control
This case breaks most often and hurts the most. A button with only an icon inside has no text, so a screen reader announces it as “button” with no name, and speech-control users have nothing to say to activate it. Name the control and hide the icon:
<!-- 1. aria-label on the control -->
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true" …>…</svg>
</button>
<!-- 2. Visually hidden text (also works with <a>, and is plain page text) -->
<a href="/cart" class="icon-link">
<svg aria-hidden="true" …>…</svg>
<span class="visually-hidden">Cart, 3 items</span>
</a>
.visually-hidden {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}Both patterns produce the same accessible name. Visually hidden text has two practical advantages: it's regular page content, so page translation tools and in-page search see it, and it can include dynamic details such as the item count.
Whichever you use, the name should say what the control does (“Close dialog”, “Search”). It should also match any visible tooltip so voice-control users can say what they see.
A title attribute on the button is not a substitute. It only appears on mouse hover after a delay, never on touch or keyboard focus, and it's used as the accessible name only when nothing better exists. Use aria-label or hidden text for the name, and a custom tooltip if sighted users need a hint too.
Icons that show state
Toggles and disclosures often change their icon: a speaker gets a slash, a chevron rotates, a star fills in. That change is visual only.
Screen readers need the state as an attribute, and the name should stay constant so users know it's the same control:
<!-- The label stays the same; the state is a separate attribute -->
<button type="button" aria-label="Mute" aria-pressed="false">
<svg aria-hidden="true" …><!-- speaker icon --></svg>
</button>
<!-- Disclosure: the icon flips, aria-expanded carries the meaning -->
<button type="button" aria-expanded="false" aria-controls="filters">
Filters
<svg aria-hidden="true" class="chevron" …>…</svg>
</button>Avoid swapping the label between “Mute” and “Unmute” while also setting aria-pressed. The two announce conflicting things (“Unmute, toggle button, pressed”). Use one approach: a fixed label with aria-pressed, or a label that changes with no pressed state.
Icons in <img> tags and sprites
When an SVG is loaded as a file, the <img> element's alt attribute is the text equivalent, and the rules are the same as for any image. Meaningful icons get a short description; decorative ones get an empty alt="".
Don't leave alt out entirely, or some screen readers will read the file name. For <use> sprites, treat the outer <svg> like an inline icon:
<!-- SVG as an image file: alt does the work -->
<img src="/icons/warning.svg" alt="Warning" width="20" height="20">
<img src="/icons/sparkle.svg" alt="" width="20" height="20"> <!-- decorative -->
<!-- Sprite reference: hide the graphic, label the control -->
<button type="button" aria-label="Search">
<svg class="icon" aria-hidden="true"><use href="#icon-search" /></svg>
</button>Accessibility you can see: contrast, size, and color
Screen reader markup is half the job. The other half is for people who see the icon but may have low vision, color blindness, or limited dexterity:
- Contrast. WCAG 1.4.11 asks for at least 3:1 against the adjacent background for icons needed to understand content or operate a control. Some common palette colors fail on white (measured below).
- Don't rely on color alone (WCAG 1.4.1). A red icon versus a green icon for error versus success needs a shape difference too, such as an × versus a ✓, or a text label.
- Target size. WCAG 2.2's 2.5.8 asks for pointer targets of at least 24×24 CSS pixels, with an exception for smaller targets that have enough spacing. Enlarge the button with padding and leave the icon at its designed size.
- Focus. Icon buttons need a visible focus indicator.
outline: 2px solid currentColorpicks up the icon's color, so it stays visible in both themes. - Forced colors. In Windows high-contrast modes, the text color is overridden, so icons painted with
currentColorfollow it. Icons with hard-coded fills don't, and can vanish against the forced background.
/* 24×24 minimum target (WCAG 2.2, 2.5.8) without enlarging the icon */
.icon-button {
display: inline-grid;
place-items: center;
min-width: 24px;
min-height: 24px;
padding: 8px; /* a 16px icon becomes a 32px target */
}
/* Visible keyboard focus */
.icon-button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Windows high-contrast / forced colors: currentColor icons follow the
forced text color automatically; hard-coded fills need a nudge */
@media (forced-colors: active) {
.icon-hardcoded path { fill: CanvasText; }
}I ran the eight preset colors in Icony's color picker through the WCAG contrast formula on white and on a dark gray (#111827):

On white, green #10B981 reaches only 2.54:1 and yellow #F59E0B 2.15:1. Red (3.76:1), blue (3.68:1) and pink (3.53:1) pass, but not by much. On #111827 every preset except black (1.18:1) passes.
Forced colors are easy to check in Chromium, which can emulate them. The same dark toolbar with two delete buttons, one icon painting with currentColor and one with stroke="#F9FAFB" written into the markup:

currentColor icon measures 21:1 against its button; the hard-coded one, 1.05:1.Five markup mistakes that undo the work
- Labeling both the button and the icon. The name gets read twice, or the icon adds a second, different name. Label one thing.
aria-labelon a plain<div>or<span>wrapper. ARIA 1.2 prohibits naming generic elements, and screen readers announce such labels inconsistently or not at all. Put the label on a button, link, orrole="img"element.- Trusting
titleon Heroicons. The component'saria-hidden="true"still hides everything unless you override it. - Describing the picture. “Magnifying glass” tells users what's drawn, not what happens. Say “Search”.
- Adding
focusable="false"everywhere. It was a workaround for Internet Explorer, which put SVGs in the tab order. Current browsers don't, so it's harmless but unnecessary.
Icons you download or copy from a customizer are usually bare graphics. Icony's SVG and React output is whatever the source library renders: Heroicons exports keep their aria-hidden="true", and the other libraries export with no role, no label, and no aria-hidden.
Only you know whether an icon is decorative or a control in the place it's used, so add or adjust the attributes there.
How to test in five minutes
- Open the browser's accessibility inspector (Chrome DevTools: Elements › Accessibility; Firefox: the Accessibility panel). Select each icon button and check that its computed Name is what you expect, and that decorative icons are absent from the tree.
- Tab through the page. Every icon control should get a visible focus ring, and nothing that isn't interactive should receive focus.
- Turn on a screen reader (VoiceOver on macOS with Cmd+F5, or NVDA on Windows) and move through the toolbar. Listen for “button” with no name, and for icons announced twice.
- Check contrast of icon colors with any WCAG contrast checker, against every background they appear on, in both light and dark themes.
For an icon-only button, the inspector should show what the last line of the snapshot above shows: a button with a name and no image inside it.