core accessibility principles for modern frameworks

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 Content
  • 2.4.7 Focus Visible
  • 2.5.8 Target Size (Minimum)
  • 4.1.1 Parsing (duplicate ids)
  • 4.1.2 Name, Role, Value
The three states an inline SVG icon can be in Three cases side by side. A decorative icon inside a labelled button is hidden with aria-hidden true and focusable false, and the button carries the name. A standalone meaningful icon, such as a status indicator with no text beside it, uses role img with an aria-label. A default unmarked icon is exposed inconsistently: some browsers announce nothing, some announce a graphics document, and in older engines it becomes a tab stop. Decorative inside a named control Close aria-hidden="true" focusable="false" Meaningful and alone a status dot, no text role="img" aria-label="Online" Unmarked no role, no hiding announced as nothing, or as a graphics document, and may take focus depending on the engine Almost every icon in a product is the first case. The second is rare; the third is a bug.

Prerequisites

  • React 18+ with SVGs imported as components (SVGR, vite-plugin-svgr, or hand-written components).
  • A place to put a shared Icon primitive 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" and focusable="false", since that covers the overwhelming majority of uses.
  • Accept an optional label; when present, switch to role="img" with aria-label and drop aria-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 1em and colour with currentColor so 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 Name problem 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.

Why exported SVG ids break on the second render Two copies of the same icon component are rendered. Both contain a clip path with the identical id. The first copy resolves its reference correctly. The second copy's reference also resolves to the first copy's definition, so when the first copy unmounts the second loses its clip path and renders wrong. The fix, shown below, is to generate a unique id per instance with useId and reference that. Instance A a clipPath named clip0 its clip-path reference resolves here Instance B — same markup the same name again — a duplicate id its reference resolves to instance A Unmount A and B loses its clip path — a rendering bug and a duplicate-id violation at the same time. Fix: one id per instance useId gives one value per instance; the definition and the reference both use it

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's prefixIds plugin 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" (and stroke="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 #1b4d8a cannot follow a palette flip.
  • Test in Windows High Contrast / forced-colors mode: icons drawn with currentColor follow the forced palette, while icons using background-image masks 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.
How an icon's paint source decides whether it survives a theme flip Two icons compared across three environments: light theme, dark theme, and forced colours. The icon painted with currentColor follows the text colour in all three, staying visible. The icon with a hard-coded navy fill looks correct in the light theme, becomes nearly invisible against the dark surface, and ignores the forced palette entirely. Light theme Dark theme Forced colours fill="currentColor" Inherits the computed text colour, so it moves with the palette. fill="#1b4d8a" baked into the file Correct once, then wrong everywhere the surface changes underneath it.
@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 title on 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.8 failure.
  • 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.