web3icons

Accessible SVG: title, role and aria attributes

How to mark up inline SVG icons for screen readers with role img, aria-label, title and aria-hidden, and how to check the result with real screen readers.

5 min read

An <img> element has an alt attribute. An inline <svg> doesn't, so you either give it a name with ARIA or hide it from assistive technology altogether. Browsers and screen readers have handled inline SVG inconsistently over the years, and a bare <svg> with no attributes can be announced as nothing, as "group", or as whatever text happens to sit inside it.

Four patterns cover nearly every icon. This guide shows each one, explains why it works, and ends with how to check the result.

First: is the SVG inline at all?

If you load the file through an image element, such as <img src="/icons/eth.svg" alt="Ethereum">, ordinary alt text rules apply. Whatever is inside the file, including any <title> or <desc>, isn't exposed to the page's accessibility tree; the image is a single opaque object. Most logo displays work fine this way, and the rules in alt text for logos and token icons are all you need.

Inline SVG makes sense when the icon must follow the text color through currentColor, respond to CSS, or animate. Inline SVG vs img vs CSS background compares the options. Everything below is about inline SVG.

Pattern 1: decorative icon

Most inline icons sit next to text that already says what they mean: the chevron in a dropdown, the arrow in "View on explorer", the check in "Copied". Hide them.

<svg aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 16 16">
  <path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>

aria-hidden="true" removes the SVG and everything inside it from the accessibility tree. focusable="false" is a leftover from Internet Explorer and the original Edge, which made inline SVG reachable with the Tab key. Current browsers don't do that, so the attribute is harmless but optional; keep it if your icon component might still render somewhere old.

Pattern 2: a meaningful icon with aria-label

When the icon stands on its own and carries information, such as a chain logo with no label or a status icon in a transaction list, give it an image role and a name:

<svg role="img" aria-label="Transaction failed" width="16" height="16" viewBox="0 0 16 16">
  <circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5" />
  <path d="M5.5 5.5l5 5m0-5l-5 5" stroke="currentColor" stroke-width="1.5" />
</svg>

role="img" matters. Without it, the <svg> element gets a graphics-specific role whose handling differs between browser and screen reader combinations; some step into the children, some skip the element, some announce a group. With role="img", the SVG is presented as one image with one name. The image role also makes its children presentational, so a stray <text> element or an exporter's leftover <title> inside is not read as a separate item.

Pattern 3: a title element, linked with aria-labelledby

SVG has its own naming element, <title>. Placed as the first child of the <svg>, it provides the accessible name in current browsers, and most desktop browsers also show it as a tooltip on hover. Because support was uneven in the past, a common belt-and-braces version points to it explicitly:

<svg role="img" aria-labelledby="usdc-title" width="32" height="32" viewBox="0 0 32 32">
  <title id="usdc-title">USD Coin</title>
  <circle cx="16" cy="16" r="16" fill="currentColor" />
</svg>

The catch is the ID. Inline SVG shares the page's ID namespace, so if the same icon appears in fifty table rows, fifty elements have id="usdc-title" and every aria-labelledby resolves to the first one. That's harmless while all rows show USDC, and wrong the moment they don't. The same duplication breaks gradients and clip paths, which SVG ID collisions covers in detail. In React, generate the ID per instance:

import { useId, type ReactNode } from 'react'

type Props = { label: string; size?: number; children: ReactNode }

export function NamedIcon({ label, size = 24, children }: Props) {
  const titleId = useId()
  return (
    <svg role="img" aria-labelledby={titleId} width={size} height={size} viewBox="0 0 24 24">
      <title id={titleId}>{label}</title>
      {children}
    </svg>
  )
}

React 19 moves <title> elements into the document head, but makes an exception for a <title> inside an <svg>, where it stays put as the SVG's label.

Which to choose? aria-label is shorter, needs no ID, and shows no tooltip. Use <title> when you want the hover tooltip as well, keeping in mind that tooltips don't appear for keyboard or touch users, so they can't be the only place important information lives.

For an icon-only control, the name belongs on the control, and the SVG is hidden:

type Props = { onCopy: () => void }

export function CopyAddressButton({ onCopy }: Props) {
  return (
    <button type="button" aria-label="Copy address" onClick={onCopy}>
      <svg aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 16 16">
        <rect x="5" y="5" width="9" height="9" rx="1.5" fill="none" stroke="currentColor" />
        <path d="M11 3H3.5A1.5 1.5 0 0 0 2 4.5V11" fill="none" stroke="currentColor" />
      </svg>
    </button>
  )
}

Naming the SVG with role="img" and letting the button take its name from its content also works in most combinations, but naming the control directly is more predictable and easier to review. The full set of options, including visually hidden text, is in icon-only buttons need names.

Longer descriptions

Icons need short names. Small charts sometimes need more. A sparkline next to a token's price can carry a name and a description (give each instance its own IDs if it repeats per row):

<svg role="img" aria-labelledby="spark-name" aria-describedby="spark-desc" viewBox="0 0 120 32">
  <title id="spark-name">ETH price, last 7 days</title>
  <desc id="spark-desc">Rising overall, with a dip on day 4.</desc>
  <polyline points="0,24 20,20 40,22 60,28 80,14 100,10 120,8" fill="none" stroke="currentColor" />
</svg>

If the same information is already in visible text nearby, skip the description and keep only the name, or hide the sparkline entirely.

Checking the result

Start with the accessibility tree. In Chrome, Edge and other Chromium browsers, the Accessibility tab in the Elements panel shows the computed role and name of the selected element. Firefox has an Accessibility Inspector in its devtools. A decorative SVG should be absent, a meaningful one should show role "image" or "img" with the right name, and an icon-only button should show role "button" with a name.

Then listen. Common pairings to test are VoiceOver with Safari on macOS and iOS, NVDA with Firefox or Chrome on Windows, and TalkBack with Chrome on Android. NVDA is free; VoiceOver and TalkBack are built in. Expect wording to vary, for example "image" versus "graphic", and focus on whether the name is right and spoken once.

Markup What you should hear
<svg aria-hidden="true"> next to text Only the text
<svg role="img" aria-label="Solana"> "Solana, image" or similar
Button with aria-label="Copy address" and a hidden SVG "Copy address, button"
Bare <svg> with an exporter's <title>Layer 1</title> Possibly "Layer 1", a group, or nothing: the result you're trying to avoid

If you hear the same name twice, an icon that should be hidden isn't. If you hear "button" with no name, an icon was hidden before its control was named.

More in Accessibility

All guides