Making Inline SVG Icons Accessible in React
Inline SVG icons are everywhere in a React codebase and they carry three accessibility hazards that plain <img> icons do not: they are focusable in some browsers, they contain id attributes that collide when the same icon renders twice, and they are exposed to assistive technology with an inconsistent role. All three are fixed by the same short checklist — and the checklist is worth encoding in one shared component rather than repeating it in two hundred call sites.
This is the icon-specific half of Accessible Images, Icons & Media in Component UIs.
WCAG Success Criteria Addressed:
1.1.1 Non-text Content2.4.7 Focus Visible2.5.8 Target Size (Minimum)4.1.1 Parsing (duplicate ids)4.1.2 Name, Role, Value
Prerequisites
- React 18+ with SVGs imported as components (SVGR,
vite-plugin-svgr, or hand-written components). - A place to put a shared
Iconprimitive that every feature imports. - A visually-hidden utility class for names that must not appear on screen.
One Icon Component, One Set of Rules
Every rule below is mechanical, which makes it a perfect fit for a single wrapper. Feature code should never write aria-hidden on an icon by hand.
Implementation Guidelines:
- Default the wrapper to decorative:
aria-hidden="true"andfocusable="false", since that covers the overwhelming majority of uses. - Accept an optional
label; when present, switch torole="img"witharia-labeland droparia-hidden. - Force
focusable="false"regardless — it is inert in modern browsers and prevents a stray tab stop in older engines and some embedded webviews. - Size with
1emand colour withcurrentColorso icons inherit the surrounding text automatically, including under a dark theme.
type IconProps = React.SVGProps<SVGSVGElement> & {
/** Omit for decorative icons inside a named control. */
label?: string;
};
export function Icon({ label, children, ...rest }: IconProps) {
const naming = label
? ({ role: 'img' as const, 'aria-label': label })
: ({ 'aria-hidden': true as const });
// A11y rationale: 4.1.2 Name, Role, Value — an unmarked graphic is exposed
// inconsistently, so every icon is explicitly either hidden or named.
return createElement(
'svg',
{ width: '1em', height: '1em', fill: 'currentColor', focusable: 'false', ...naming, ...rest },
children,
);
}
Naming the Control, Not the Glyph
An icon button has exactly one name, and it should describe the action. Where that name lives is a design-system decision worth making once.
Implementation Guidelines:
- Prefer visually hidden text inside the button over
aria-label: it is translated by page-translation tools, matched by in-page search, and visible in the DOM during debugging. - Keep the name imperative and specific — "Delete draft", not "Delete" and certainly not "Trash icon".
- If the button also has a tooltip, make the tooltip text and the accessible name identical. Two different strings for one control is a
2.5.3 Label in Nameproblem the moment voice control is involved. - Pad the button to at least 24×24 CSS pixels; scaling the glyph itself makes the icon look wrong long before the target is big enough.
export function IconButton({ icon, label, ...rest }: IconButtonProps) {
return (
<button type="button" className="icon-button" {...rest}>
{icon}
<span className="visually-hidden">{label}</span>
</button>
);
}
/* 2.5.8 Target Size — pad the control, do not inflate the glyph. */
.icon-button {
display: inline-grid;
place-items: center;
min-width: 24px;
min-height: 24px;
padding: 0.4rem;
}
Duplicate ids Are the Sprite Tax
SVGs exported from design tools are full of id attributes — gradients, clip paths, masks, filters. Render that icon twice and the page has duplicate ids, which is both an accessibility violation and a rendering bug: the second instance references the first instance's definitions, and disappears when the first unmounts.
Implementation Guidelines:
- Generate per-instance ids with React's
useId()and interpolate them into both the definition and the reference. - Configure SVGR (or your equivalent) to prefix ids automatically —
svgo'sprefixIdsplugin does this at build time for every icon at once. - Strip ids you do not need. Most exported gradients and clip paths are artefacts of the design tool and can be flattened away.
- Do the same for
<title>elements exported from design tools; they become tooltips and stale names nobody intended.
export function BadgeIcon(props: React.SVGProps<SVGSVGElement>) {
const id = useId(); // unique per instance, stable across renders
const clipId = `${id}-clip`; // one value, used by both the definition and the reference
// The clipPath is rendered with id={clipId}; the group that uses it sets
// the group that uses it builds its clip-path reference from clipId too, so two
// instances on one page can never collide.
return renderBadge({ clipId, ...props });
}
Theming: currentColor and Forced Colors
An icon that stays dark on a dark background is an accessibility failure even though no ARIA attribute is wrong. Two mechanisms cover almost every case.
Implementation Guidelines:
- Paint with
fill="currentColor"(andstroke="currentColor"where relevant) so the icon inherits the computed text colour in both themes. - Never bake theme colours into the SVG file. A hard-coded
#1b4d8acannot follow a palette flip. - Test in Windows High Contrast / forced-colors mode: icons drawn with
currentColorfollow the forced palette, while icons usingbackground-imagemasks can vanish. - For meaningful colour — a red error glyph, a green success glyph — pair the colour with a shape difference and a text label, since colour alone fails
1.4.1 Use of Color.
@media (forced-colors: active) {
/* Keep icon strokes visible when the system palette takes over. */
.icon-button svg {
forced-color-adjust: auto;
}
}
How to Verify
- Tab-count check. Tab through a page dense with icons. Every stop should be a real control; landing on an icon itself means
focusable="false"is missing somewhere. - Screen reader pass. Listen to a toolbar of icon buttons. You should hear one name per control, with no "graphic", no "image", and no repeated name.
- Duplicate-id scan. Run axe and watch
duplicate-id; then render the same icon twice on one page and confirm both instances still draw correctly. - Theme flip. Switch to the dark theme and confirm every icon changes colour with its text. Anything that stays put has a hard-coded fill.
- Target-size check. Measure a few icon buttons in DevTools. Anything under 24×24 CSS pixels fails
2.5.8 Target Size (Minimum).
Common Accessibility Mistakes
- Naming both the icon and the button. Produces "Close close, button". Hide the glyph; name the control.
- Leaving
<title>inside an exported icon. It becomes a tooltip and sometimes the accessible name — usually reading "Layer 1" or "Vector". - Using
titleon the button instead of a real name. Tooltips are unreliable for naming and invisible on touch. - Rendering the same sprite ids on every instance. Duplicate ids break both the accessibility scan and the rendering.
- Icon-only buttons under 24 pixels. Common in dense toolbars, and a straightforward
2.5.8failure. - Hard-coded fills. The icon that stays navy on a dark surface is the most visible dark-mode bug a design system ships.
Conclusion
Inline SVG icons need four things, and all four belong in one shared component: hide the glyph unless it is genuinely standalone, name the control that contains it, force focusable="false", and generate unique ids per instance. Add currentColor painting and 24-pixel targets and the whole category stops producing audit findings. The reason to centralise is not elegance — it is that every one of these rules is invisible in a design review, so the only reliable place to enforce them is the code path every icon already passes through.
Frequently Asked Questions
Is focusable="false" still necessary?
In current Chrome, Firefox and Safari, no — inline SVG is not focusable by default. It remains worth setting because some embedded webviews and older engines still make SVG a tab stop, the attribute is inert everywhere else, and a stray tab stop is the kind of bug nobody tests for until a user reports it.
When should an icon have its own accessible name?
Only when it is the sole carrier of information and is not inside a named control — a status dot with no adjacent text, a rating shown purely as stars. In that case use role="img" with aria-label. Everywhere else, the containing button, link or heading owns the name.
Should I use an SVG sprite or per-icon components?
Either works. Sprites reduce duplicate markup on icon-heavy pages; per-icon components tree-shake better and keep ids naturally scoped when combined with useId. The accessibility rules are identical: hide the <use> wrapper, name the control, avoid cross-origin sprite URLs.
Do icons need role="presentation" as well as aria-hidden?
No. aria-hidden="true" removes the element and its subtree from the accessibility tree entirely, which is what you want. Adding role="presentation" on top is redundant and occasionally confusing to read.
How should I handle animated icons?
Treat the animation as decoration and the meaning as text. A spinner should be aria-hidden with the status announced by a live region ("Saving…"), not by the graphic itself. Any looping animation also needs to respect prefers-reduced-motion: pause it, or swap to a static frame. An icon that conveys progress purely through movement is invisible to anyone who has motion turned off.
Do inline SVGs hurt bundle size compared with a sprite? Only when the same icon is repeated many times on one page, since each instance ships its own path data. In practice the difference is small for typical icon sets, and per-icon components tree-shake unused glyphs out of the bundle entirely — which usually wins over a sprite that ships every icon to every page. Measure before optimising; accessibility is unaffected either way.
How do icon fonts compare? Poorly. Icon fonts render as text characters, which means they can be announced as random glyphs, they break when a user forces their own font, and they are affected by text-only zoom in ways SVG is not. Inline SVG is the better default in every respect that matters here.
Related guides
- Accessible Images, Icons & Media in Component UIs — the parent guide, including sprites and figures.
- Writing Alt Text for Component-Driven UIs — the same ownership problem for photographic content.
- Accessible Color Contrast & Theming — currentColor, palettes and forced-colors mode.
- Accessible Focus Indicators in Design Systems — making the ring on an icon button visible.