SVG Icon Accessibility: aria-label, role and title Explained Properly
Stop guessing which ARIA attributes to put on your SVG icons. Here's exactly when to use aria-label, role, title and aria-hidden — with React examples.
Why SVG Icons Break Screen Readers (More Often Than You'd Think)
Most developers assume icons are a minor detail. They're not. A screen reader user navigating your UI with keyboard-only access hits every focusable icon button, and if you haven't annotated them properly, they hear "button" or — worse — the raw SVG path data read aloud. That's not hypothetical. It was a documented VoiceOver bug in Safari 15 that persisted into 16.
The root problem is that SVG isn't HTML. Browsers don't automatically infer semantics the way they do for a <button> or <img>. You, the developer, have to tell the accessibility tree what the icon is, whether it's meaningful, and what label a user should hear. Three attributes handle almost everything: aria-label, role, and <title>. The catch is that they overlap, conflict, and behave differently across browser/screen-reader combos. Honestly, the MDN docs don't make it obvious which one wins when you stack them.
Worth noting: the rules differ depending on whether the SVG is standalone (its own element), embedded inside a <button>, or used as a CSS background. This article focuses on inline SVGs in React because that's where 90% of real bugs live. Quick aside: if you're building icon-heavy UIs, Empire UI's component library ships pre-annotated icon wrappers you can use as a reference baseline.
One more thing — none of this replaces an actual audit. Run axe-core or Lighthouse accessibility after you ship. The patterns below will get you to passing, but real users with AT software catch things automated tools miss.
Decorative vs Meaningful: The First Decision You Have to Make
Before you touch a single ARIA attribute, answer one question: does this icon carry meaning on its own, or is it purely decorative? That split determines everything.
A decorative icon sits next to visible text that already conveys the same information. The star icon beside the text "Favorites" is decorative — the word already says it. A meaningful icon is standalone: a close button with only an ✕ SVG inside it, no text, carries all the meaning itself. If you hide that icon from screen readers, the user has no idea what the button does.
In React, marking an icon as decorative is two attributes: aria-hidden="true" and nothing else. No role, no title, no label. Done. The accessibility tree ignores it completely, which is exactly what you want.
// Decorative — icon adds no info beyond the visible label
<button>
<StarIcon aria-hidden="true" focusable="false" />
Favorites
</button>The focusable="false" part matters in IE11 and older Edge — SVG elements are focusable by default in those browsers, which creates phantom tab stops. You probably don't ship to IE11 in 2026, but the attribute is harmless and defensive. Keep it.
aria-label on the Icon vs on the Button — Get This Right
Here's where developers make the most mistakes. When an icon is inside a <button>, don't put aria-label on the SVG. Put it on the button. The button is the interactive element the accessibility tree exposes — not the SVG inside it. Screen readers announce the button's accessible name, and that name comes from its aria-label, its text content, or its aria-labelledby, in that order of precedence.
// WRONG — aria-label on the svg doesn't help the button's accessible name
<button>
<CloseIcon aria-label="Close dialog" />
</button>
// RIGHT — label on the interactive element
<button aria-label="Close dialog">
<CloseIcon aria-hidden="true" focusable="false" />
</button>In practice, developers see this mistake consistently in design systems that ship icon components with built-in aria-label props. The component author meant well, but if you drop <CloseIcon label="Close" /> inside a button, you now have a labelled SVG inside an unlabelled button — and the button wins the announcement race with zero useful text.
That said, there's one valid case for aria-label directly on an SVG: when the SVG itself is the interactive element (which is rare but valid). If you're building a custom <svg role="button"> for some reason, aria-label on the SVG is correct. But you'd almost never need to do that in a React app with access to real button elements.
Look, use real <button> elements. Don't build clickable SVGs. The browser gives you keyboard handling, focus management and ARIA semantics for free with an actual button. Only fight that if you have a very specific reason.
The <title> Element: When It Works and When It Doesn't
SVG has a native <title> child element — the SVG equivalent of an HTML alt attribute. The spec says screen readers should announce it. The reality in 2026 is that support is inconsistent. VoiceOver on macOS announces it. NVDA + Firefox does too. JAWS is hit or miss depending on version. NVDA + Chrome frequently ignores it entirely.
// Using <title> — works in some AT, not all
<svg role="img" aria-labelledby="icon-title">
<title id="icon-title">Upload file</title>
<path d="M12 2L18 8H14V14H10V8H6L12 2Z" />
</svg>The aria-labelledby pointing at the <title>'s id is the key. Without it, many screen readers don't pick up the title at all — even browsers that technically support SVG <title> semantics. Always pair them. And give the title id a unique value; duplicate ids break aria-labelledby in ways that are painful to debug.
Worth noting: if you use aria-label on the <svg> alongside a <title>, the aria-label wins and the title is ignored for announcement purposes. Pick one approach per icon, don't stack both.
In practice, aria-label on the wrapper element (button or the SVG with role="img") is more reliable cross-browser than relying on <title> alone. Use <title> as a tooltip fallback and for document semantics, but don't depend on it as your only accessibility hook.
role="img": When and Why to Use It
By default, an inline <svg> has no implicit ARIA role. That means screen readers may skip it, announce it as a generic group, or in older AT, read out its raw path content. Adding role="img" tells the accessibility tree: "treat this as a single image unit."
You need role="img" when the SVG is meaningful and standalone — not inside a button, not next to visible text. The classic case: an icon-only link, or an informational illustration that has no caption.
// Standalone meaningful SVG
<a href="/dashboard">
<svg
role="img"
aria-label="Go to dashboard"
width="24"
height="24"
viewBox="0 0 24 24"
>
<path d="..." />
</svg>
</a>One more thing — some linters and axe-core rules flag role="img" without an accessible name as a violation (ARIA 1.2 requires an accessible name for landmark and widget roles that aren't presentation). So role="img" without aria-label or aria-labelledby will fail audits. Always pair them.
Decorative SVGs that live completely outside interactive elements should use role="presentation" or aria-hidden="true" — not role="img". The distinction: aria-hidden removes the element from the tree entirely; role="presentation" keeps it in the tree but strips its semantic role. For icons, aria-hidden="true" is almost always what you actually want.
A Reusable React Icon Wrapper That Gets It Right
Instead of making these decisions on every icon, build a thin wrapper component once and use it everywhere. Here's the pattern that handles both the decorative and meaningful cases cleanly.
// Icon.tsx
import { SVGProps } from 'react';
interface IconProps extends SVGProps<SVGSVGElement> {
label?: string; // if provided, icon is meaningful; if omitted, decorative
size?: number;
}
export function Icon({
label,
size = 24,
children,
...props
}: IconProps) {
const isMeaningful = Boolean(label);
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
role={isMeaningful ? 'img' : undefined}
aria-label={isMeaningful ? label : undefined}
aria-hidden={isMeaningful ? undefined : true}
focusable="false"
{...props}
>
{children}
</svg>
);
}The logic is dead simple. Pass a label and the icon announces itself. Omit it and it's invisible to AT. The focusable="false" stops the old IE/Edge phantom-focus issue. You don't have to remember the rules at each call site — the component enforces them for you.
When you use this inside a button, omit label and put aria-label on the button instead. When you use it as a standalone meaningful graphic, pass label. That covers every case you'll hit in a real design system. If you're building components that need to match a visual style system, Empire UI's component library pairs well with this pattern — the style tokens handle the visual layer while your Icon wrapper handles the semantics.
That said, if your project already pulls icons from a library like Lucide or Radix UI Icons, check their docs before writing your own wrapper. Radix Icons (0.x → 1.x) changed their default ARIA handling between versions, and you don't want your wrapper fighting theirs.
Testing Your Icon Accessibility Without a Full AT Setup
You don't need JAWS or a physical screen reader to catch most icon accessibility issues. axe-core (the engine behind axe DevTools) catches missing labels, incorrect role combos, and duplicate IDs in under a second. Install it as a Jest matcher with @axe-core/react and your tests will fail the moment a role="img" ships without a name.
// In your Jest setup file
import { configureAxe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
expect.extend(toHaveNoViolations);
test('CloseButton is accessible', async () => {
const axe = configureAxe();
const { container } = render(
<button aria-label="Close dialog">
<CloseIcon aria-hidden="true" focusable="false" />
</button>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});For manual spot-checking, macOS VoiceOver is free and built in. Enable it with Cmd+F5, navigate to your page with Tab, and listen. If you hear "button" with no context, something's wrong. If you hear the icon's SVG path data, you forgot aria-hidden on a decorative icon inside a labelled button — a surprisingly common bug.
Chrome's built-in Accessibility panel (DevTools → Elements → Accessibility) shows the computed accessible name for any element. Inspect your icon buttons there first — it's faster than firing up AT for a quick sanity check. Worth noting: the panel shows what Chrome computes, which doesn't always match what NVDA or JAWS announces, so don't stop there for production validation.
FAQ
On the button. The button is the focusable element with an accessible name; the SVG inside it should have aria-hidden="true". Putting aria-label on the SVG doesn't give the button a name.
Not reliably. Browser/screen-reader support is inconsistent, especially NVDA + Chrome. Always pair <title> with aria-labelledby pointing at the title's id, or just use aria-label on the parent element instead.
When the SVG is meaningful and standalone — not inside a button or next to visible text. Always pair it with aria-label or aria-labelledby, or axe-core will flag it as a violation.
aria-hidden="true" removes the element from the accessibility tree entirely. role="presentation" strips its semantic role but keeps it in the tree. For decorative icons, aria-hidden is almost always the right choice.