core accessibility principles for modern frameworks

Accessible Images, Icons & Media in Component UIs

Alt text is the oldest accessibility requirement on the web and still the most frequently botched, because component frameworks hide the decision behind a prop. An <Avatar user={user} /> renders an <img> somewhere three files away, and nobody remembers who was supposed to decide whether that image carries meaning. This guide covers how to make that decision, how to encode it in component APIs so it cannot be skipped, and how the same reasoning extends to icons, inline SVG diagrams, charts and video.

It sits under Core Accessibility Principles for Modern Frameworks, and the contrast side of visual design — including diagram colours — is covered separately in Accessible Color Contrast & Theming.

WCAG Success Criteria Addressed:

  • 1.1.1 Non-text Content
  • 1.2.2 Captions (Prerecorded)
  • 1.2.3 Audio Description or Media Alternative
  • 1.4.5 Images of Text
  • 4.1.2 Name, Role, Value
Decision tree for choosing alt text A decision tree starting from an image in a component. First question: does it do something when activated? If yes, the alt text names the action, not the picture. If no, ask whether the surrounding text already conveys the same information: if yes, the image is decorative and takes an empty alt attribute; if no, ask whether the content is complex, such as a chart or diagram, in which case a short alt plus a longer description nearby is required, otherwise a single sentence of alt text describing the meaning is enough. An image in a component it is a control it is content Name the action "Close dialog", not "grey cross icon" Does nearby text already say the same thing? yes no Decorative alt="" — removed from the accessibility tree Describe the meaning one sentence; add a long description if complex The question is never "what does it look like" — it is "what would a reader lose if it vanished".

The Decision Comes Before the Markup

Every image falls into one of three buckets, and the bucket — not the file — determines the markup. Functional images do something when activated. Informative images carry content the surrounding prose does not. Decorative images carry nothing a reader would miss.

Implementation Guidelines:

  • Functional: the accessible name describes the action ("Delete comment"), never the artwork.
  • Informative: the alt text conveys the information the image adds, in the shortest sentence that does the job.
  • Decorative: alt="" (an empty string, not a missing attribute). A missing alt makes some screen readers announce the file name, which is the worst of both worlds.
  • Never start alt text with "image of" or "picture of" — the role is already announced.
{/* Functional: the alt text is the action. */}
<button onClick={remove}>
  <img src="/icons/trash.svg" alt="Delete comment" />
</button>

{/* Informative: alt carries what the picture adds. */}
<img src={chart} alt="Support tickets fell 40% after the July release." />

{/* Decorative: explicitly empty, so it is skipped rather than guessed at. */}
<img src="/patterns/divider.svg" alt="" />

Images of text deserve a separate note. 1.4.5 Images of Text asks you to use real text wherever the presentation allows it — real text reflows, respects the user's font size, survives translation, and inherits the theme. A screenshot of a code sample is an image of text; a rendered code block is not.

Encoding the Decision in the Component API

If a component makes alt text optional, some caller will omit it. TypeScript can force the decision instead, by making the type of an image component a union of "described" and "decorative".

Implementation Guidelines:

  • Model the two cases as a discriminated union so decorative and alt cannot both be omitted.
  • Do not default alt to the file name, the title, or any other incidental string — an inaccurate name is worse than none.
  • For avatars and thumbnails, decide once at the component level and document it, so callers do not each invent their own answer.
  • Expose the raw <img> props for the rest, so nobody has to fork the component to add loading or sizes.
type DescribedImage = { alt: string; decorative?: never };
type DecorativeImage = { decorative: true; alt?: never };

type ImageProps = (DescribedImage | DecorativeImage) &
  Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'alt'>;

// A11y rationale: 1.1.1 Non-text Content — the caller must state which case this
// is; there is no way to render the component without making the decision.
export function Image({ decorative, alt, ...rest }: ImageProps) {
  return <img alt={decorative ? '' : alt} {...rest} />;
}

The same trick works for icon buttons: require either a visible label as children or an aria-label prop, and reject the component that has neither. Pushing these rules into types means the failure shows up in the editor rather than in an audit six months later. Where types cannot reach — dynamic content, CMS-supplied images — a lint rule is the next line of defence, as covered in Accessibility Linting in Editors & Builds.

Icons: Hidden Glyph, Named Control

An icon almost never needs its own name. What needs a name is the control the icon sits inside. The reliable pattern is therefore: hide the glyph, name the button.

Anatomy of an accessible icon button A button containing an icon. Three annotations point at it. The first shows the glyph itself marked aria-hidden true so the decorative shape is not announced. The second shows the button carrying the accessible name, either as visually hidden text or an aria-label. The third shows the hit area, at least twenty-four by twenty-four CSS pixels to satisfy target size. A panel on the right lists what a screen reader announces: the name, then the role button, then the state if any. icon hit area ≥ 24 × 24 px (2.5.8 Target Size) <svg aria-hidden="true" focusable="false"> <button aria-label="Close"> What the screen reader says 1. "Close" — the accessible name 2. "button" — the role 3. "collapsed" — the state, if any The glyph itself is never announced. Naming the glyph instead of the button produces "close close button" — one control, two names.

Implementation Guidelines:

  • Put aria-hidden="true" on the icon and focusable="false" on the inline svg element — IE-era focus behaviour still surfaces in some embedded browsers.
  • Name the button with visible text where space allows; use a visually hidden <span> or aria-label only when it does not.
  • Keep the interactive target at least 24×24 CSS pixels (2.5.8 Target Size (Minimum)), padding the button rather than scaling the glyph.
  • Never rely on title for the name. It is inconsistently announced and invisible to touch users.
// Visually hidden text is the most robust name: it survives translation
// tooling, is copied by "find in page", and cannot drift from the tooltip.
export function IconButton({ icon: Icon, label, ...rest }: IconButtonProps) {
  return (
    <button type="button" {...rest}>
      <Icon aria-hidden="true" focusable="false" />
      <span className="visually-hidden">{label}</span>
    </button>
  );
}

Inline SVG That Actually Reads

Inline SVG is the default for diagrams in a modern stack, and its accessibility contract is different from <img>. An svg element has no implicit role in most browsers, so an unlabelled diagram is announced as a group of anonymous shapes — or skipped entirely.

Implementation Guidelines:

  • Add role="img" to the root svg element so it is exposed as a single image rather than a shape soup.
  • Provide <title> for the short name and <desc> for the longer explanation, wired up with aria-labelledby referencing both IDs.
  • Give every id a page-unique value. Two diagrams sharing id="title" produce a duplicate-id violation and unpredictable naming.
  • Write the <desc> as the sentence you would say out loud to explain the diagram — that text is the diagram for a screen reader user.
<!-- Root attributes: role="img", plus aria-labelledby naming both ids below. -->
<title id="flowTitle">Focus restoration after a dialog closes</title>
<desc id="flowDesc">The trigger button opens a dialog; focus moves inside it and is trapped while it is open; pressing Escape closes the dialog and returns focus to the trigger.</desc>

Diagrams also have to survive theming. Colours baked into an SVG stay light when the page goes dark, so drive fills and strokes from the same custom properties as the rest of the interface, and give every diagram a full-canvas background rect so floating labels keep their contrast in both themes.

Charts and Other Complex Images

A chart carries more information than one sentence of alt text can hold. The accessible answer is not a longer alt — it is a short name plus a real, visible alternative that everyone can use.

Implementation Guidelines:

  • Give the chart a one-line accessible name that states its subject and headline finding.
  • Put the underlying numbers on the page, either as a table beside the chart or behind a disclosure. A visible data table beats any hidden long description.
  • Do not encode meaning in colour alone (1.4.1 Use of Color): add direct labels, patterns, or shape markers.
  • Keep interactive charts keyboard-operable, or provide a non-interactive equivalent of the same data.
<figure>
  <RevenueChart data={quarters} aria-labelledby="rev-caption" />
  <figcaption id="rev-caption">
    Quarterly revenue, 2024–2026. Revenue grew each quarter except Q3 2025.
  </figcaption>

  <details>
    <summary>View the data as a table</summary>
    <QuarterTable data={quarters} />
  </details>
</figure>

The pattern generalises: whenever an image encodes structured data, ship the structure. Accessible data tables have their own requirements, covered in Accessible Data Tables & Grids in React.

Figures, Captions and the Name They Produce

<figure> and <figcaption> look like a styling convenience and are actually a naming mechanism: when a figure has a caption, that caption becomes the figure's accessible name. Used well, it removes the awkward choice between a visible caption and hidden alt text.

Implementation Guidelines:

  • Use <figure> when the image is referenced from the prose ("the diagram below"), not for every image on the page.
  • Let the caption say what the reader needs and keep alt complementary rather than identical — hearing the same sentence twice is a common and avoidable annoyance.
  • For a diagram whose caption already carries the meaning, alt="" on the image is correct: the caption is doing the naming.
  • Do not put a caption inside the alt attribute as well "just in case". Duplication is the single most common finding in a manual image audit.
<figure>
  <img src="/diagrams/focus-order.svg" alt="" />
  <figcaption>
    Tab order follows the DOM: the modal renders at the end of body, so focus
    must be moved into it explicitly when it opens.
  </figcaption>
</figure>

The same principle scales to code screenshots, dashboards and tables of numbers: whichever element carries the explanation should carry the name, and everything else should stay quiet.

Video and Audio

Media has the most prescriptive requirements in WCAG, and the most expensive to retrofit — captions produced during editing cost a fraction of captions commissioned after launch.

Which alternative each media type needs A matrix with three rows and three requirement columns. Prerecorded video with sound needs captions and audio description, and benefits from a transcript. Prerecorded audio only needs a transcript. Silent video needs an audio description or a text alternative but no captions. A note adds that autoplaying media longer than three seconds must offer a pause control. Media type Captions Audio description Transcript Video with sound (prerecorded) required required recommended Audio only (podcast, voice note) n/a n/a required Silent video (looping demo) n/a or text alternative recommended

Implementation Guidelines:

  • Ship captions as a <track kind="captions"> file rather than burning them into the video, so users can restyle or disable them.
  • Provide a transcript on the page for audio-only content; it is also the cheapest way to make spoken content searchable.
  • Give any autoplaying media longer than three seconds a visible pause control (2.2.2 Pause, Stop, Hide), and respect prefers-reduced-motion for background video — see Reduced Motion & Animation Accessibility.
  • Keep custom player controls as real buttons with names and states; a <div> play control fails both keyboard and screen reader use.
<video controls preload="metadata" poster="/covers/intro.png">
  <source src="/media/intro.mp4" type="video/mp4" />
  <track kind="captions" src="/media/intro.en.vtt" srcLang="en" label="English" default />
  <track kind="descriptions" src="/media/intro.desc.vtt" srcLang="en" label="Descriptions" />
</video>

Card layouts wrap an image, a heading and a summary in a single link, which is where accessible names go wrong most often. The link's name is the concatenation of everything inside it, so an image with descriptive alt text produces a link announced as "Photo of a laptop on a desk, Focus management in single-page apps, link" — the same destination described twice.

Implementation Guidelines:

  • When a link already contains its own visible text, mark the image inside it decorative. The heading is the name; the picture is illustration.
  • When a link contains only an image — a logo linking home, a thumbnail linking to an article — the image's alt text becomes the link's name, so write it as a destination: alt="Modern Framework Accessibility home".
  • Keep one link per destination. Two links to the same URL in one card (image link plus title link) doubles the tab stops and reads as two identical entries in the links list.
  • Do not add title attributes on top of the name; they duplicate or, worse, conflict with it.
// One link, one name. The image is illustration, so it is explicitly decorative.
export function GuideCard({ guide }: { guide: Guide }) {
  return (
    <article className="content-card">
      <img src={guide.cover} alt="" />
      <h3>
        <a href={guide.href}>{guide.title}</a>
      </h3>
      <p>{guide.summary}</p>
    </article>
  );
}

If the whole card must be clickable, keep the single <a> around the title and stretch its hit area with CSS (a::after { position: absolute; inset: 0; }) rather than nesting the card contents inside the anchor. The pointer behaviour is identical, the accessible name stays clean, and the heading remains a heading in the outline instead of being swallowed by link text.

Optimised Images Still Need Names

Framework image components — next/image, nuxt-img, and their equivalents — add lazy loading, responsive srcset generation and layout-shift protection. None of that changes the accessibility contract, but each one introduces a way to lose it.

Implementation Guidelines:

  • Keep alt explicit on every optimised image. Most of these components accept it, and most of them silently render without it.
  • Set width and height (or fill with a sized container) so the layout does not shift as images arrive. Layout shift is a usability failure for everyone and a serious one for users magnifying the screen.
  • Do not lazy-load images that are visible on first paint. A late-loading hero delays the largest contentful paint and, for a screen magnifier user, moves content mid-read.
  • When an image is purely presentational, mark it decorative and consider whether it belongs in CSS instead — a background image needs no alt decision at all.
import Image from 'next/image';

// Priority + explicit dimensions: no layout shift, no lazy-loaded hero.
<Image
  src="/covers/focus-management.png"
  alt="A dialog closing and returning focus to the button that opened it."
  width={1200}
  height={630}
  priority
/>

Art direction adds one more wrinkle. When <picture> swaps a wide crop for a tall one, the two crops may not carry the same information — a chart cropped for mobile can lose its legend. The alt lives on the <img> and does not change with the source, so write it to describe what every crop still shows, or serve the same content in both.

Dynamic and User-Generated Images

The images you control are the easy half. Avatars, CMS uploads and third-party embeds arrive without alt text unless the pipeline asks for it, and a component that guesses produces confident nonsense.

Implementation Guidelines:

  • Ask for alt text at upload time, in the same form as the file, and treat it as a required field for anything editors will place in prose.
  • Store the decorative case explicitly. A nullable altText column cannot distinguish "decorative" from "nobody filled this in".
  • For avatars, derive the name from the account, not the file: alt={${user.name}'s profile photo} — or mark the avatar decorative when the name is already rendered beside it, which is usually the better call.
  • Never fall back to the filename, the slug, or a caption that repeats surrounding text. Duplicate announcements are as disruptive as missing ones.
type Upload = { url: string; alt: string | null; decorative: boolean };

// A11y rationale: 1.1.1 Non-text Content — the model records an editor's actual
// decision, so a missing description is a content bug we can report, not a guess.
export function CmsImage({ upload }: { upload: Upload }) {
  if (upload.decorative) return <img src={upload.url} alt="" />;
  if (!upload.alt) {
    reportMissingAltText(upload.url); // surfaces in the content dashboard
  }
  return <img src={upload.url} alt={upload.alt ?? ''} />;
}

The reporting call matters more than it looks. Without it, missing descriptions are invisible until an audit; with it, the content team gets a list of exactly which assets need attention, which turns an accessibility problem into an ordinary content task.

Icon Sprites and Shared Symbol Sheets

Most design systems ship icons as a sprite: one SVG document of <symbol> definitions, referenced with <use href="#icon-close">. The technique is efficient and introduces two accessibility details worth knowing.

Implementation Guidelines:

  • Keep names on the referencing element, not inside the symbol. A <title> buried in the sprite is announced inconsistently and cannot vary by usage — the same close icon may mean "Close dialog" in one place and "Dismiss notification" in another.
  • Mark the <use> wrapper aria-hidden="true" and let the surrounding button carry the name, exactly as with any other icon.
  • Inline the sprite into the document or fetch it same-origin; an external sprite referenced cross-origin is blocked by most browsers and leaves an empty box with no fallback.
  • Set fill="currentColor" on symbol paths so icons inherit text colour and flip with the theme automatically, instead of staying dark on a dark surface.
<button type="button" aria-label="Dismiss notification">
  <!-- inline graphic: aria-hidden="true" focusable="false" width="16" height="16" -->
  <!-- with a single child: use href="/sprite.svg#icon-close" -->
</button>

Because currentColor resolves against the button's computed colour, the same sprite works in both themes with no duplicated assets — the same principle that keeps inline diagrams readable when the palette flips.

How to Verify

  • Automated scan. axe's image-alt, role-img-alt, svg-img-alt, input-image-alt and button-name rules catch missing names. They cannot judge whether a name is accurate, which is why the manual passes below matter.
  • Turn images off. Load the page with images blocked. Informative images should leave a readable sentence behind; decorative ones should leave nothing.
  • Screen reader pass. Move through the page with VoiceOver or NVDA and listen for "image", file names, or duplicated names on icon buttons. Any of the three points at a mislabelled asset.
  • Zoom to 400%. Images of text become illegible when scaled; real text reflows. This is the fastest way to find screenshots that should have been markup.
  • Caption check. Play each video with sound off and confirm the captions carry the meaning, including speaker changes and significant non-speech audio.

Key Takeaways

  • Classify every image as functional, informative or decorative before writing any markup — the bucket dictates the answer.
  • Make the decision unavoidable in the component API, with a union type that has no valid "neither" case.
  • Hide the glyph, name the control: aria-hidden="true" on the icon, a real name on the button.
  • Inline SVG needs role="img" plus <title>/<desc> wired through aria-labelledby, with page-unique ids.
  • Complex images get a short name plus a real alternative — usually a visible data table.
  • Captions, audio description and transcripts are cheap during production and expensive afterwards.

Frequently Asked Questions

Is alt="" ever wrong for a decorative image? Only when the image is not actually decorative. If a reader who cannot see it would ask "what was that?", it carries meaning. A useful test: read the page aloud without the image. If a sentence stops making sense, the image was informative.

Should an icon button use aria-label or visually hidden text? Both are conformant. Visually hidden text is more robust in practice: it is translated by page-translation tools, found by in-page search, and cannot silently diverge from a tooltip. Use aria-label when the extra element genuinely gets in the way of the layout.

Does an inline SVG need role="img" if it already has a <title>? Yes. Without an explicit role, browsers expose the svg element inconsistently — some as graphics-document, some as nothing useful — and the <title> may be announced as a tooltip rather than a name. role="img" plus aria-labelledby is the combination that behaves the same everywhere.

How long can alt text be? Long enough to convey the meaning and no longer; one or two sentences is typical. If it needs a paragraph, the image is complex and needs a real alternative on the page instead — a table, a caption, or a description in the surrounding prose.

Do decorative background images need anything? CSS background images are already invisible to assistive technology, which is correct for decoration. If an image carries meaning, it should not be a CSS background at all — move it into the markup where it can have a name.

Are automatic captions good enough? As a starting point, not as a deliverable. Machine captions typically run 85–95% accurate and mangle exactly the words that matter — names, product terms, numbers. Edit them before publishing, and always mark speaker changes.