An accessible lazy image has its alt text, width and height in the server-rendered markup from the start, uses a placeholder that assistive technology ignores, and falls back to readable text if the real image never loads — the observer only ever swaps the source.

Problem / Scenario Context

A recipe site lazy-loads step photos with the pattern from building a lazy image loader. The markup ships as <img data-src="step-3.jpg" class="lazy"> and the observer's callback sets src and, from the CMS data, alt. Screen-reader users reading the steps hear "image" or a file name for every photo that has not scrolled into view — and since screen readers do not always scroll the page, many photos never load and are never described. Meanwhile, sighted users see text jump as photos pop in with no reserved height.

The observer is doing its job. The accessible parts were simply moved into the callback, which only runs if the viewport gets there. The Accessible Observer-Driven Interfaces topic states the principle: anything a user needs to perceive must not depend on the observer firing.

Mechanics Explanation

The accessibility tree is built from the DOM, not from the pixels. An <img> element is exposed as an image with an accessible name taken from alt whether or not its source has loaded. So:

  • alt present from the start → the image is described immediately, even if it never loads.
  • alt added later → until then, screen readers fall back to the file name, the title, or just "image" — or skip it, depending on the reader.
  • alt="" → the image is decorative and ignored, which is right for placeholders and decorative art and wrong for content.

Layout works the same way: an <img> with width and height attributes gets an aspect ratio from the browser's default aspect-ratio: auto <width> / <height> mapping, so its box has the right size before any bytes arrive. Without them, the box is zero-height until load, and everything below shifts — visible as layout shift and harmful to screen-magnifier users, who lose their place.

What the Accessibility Tree Sees Before LoadTwo columns. When alt text is added by the observer callback, an unloaded image is announced as a file name or just image, and has no height so content jumps when it loads. When alt text and dimensions are in the initial markup, the image is described correctly before load, its space is reserved, and only the pixels arrive later.alt added in the callbackAnnounced as a file name, or just "image"Never described if the page is not scrolled to itZero height until load, so text jumpsalt and size in the markupDescribed correctly before any bytes loadReadable even if the load never happensSpace reserved; only pixels arrive later

Comparison Table: Markup Choices

Markup Described before load? Space reserved? Works without JS?
<img data-src alt> set by callback no no no
<img data-src alt="…" width height> yes yes no — needs <noscript>
<img src="placeholder.svg" data-src alt="…" width height> yes yes shows placeholder only
<img src="real.jpg" loading="lazy" alt="…" width height> yes yes yes
Background image via CSS no only via container depends

Minimal Reproducible Example

HTML
<!-- Inaccessible: nothing to describe, no size, until the callback runs -->
<img class="lazy" data-src="/img/step-3.jpg" data-alt="Whisking egg whites to stiff peaks">
TypeScript
new IntersectionObserver((entries, obs) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const img = e.target as HTMLImageElement;
    img.src = img.dataset.src!;
    img.alt = img.dataset.alt!;       // too late for anyone who has not scrolled here
    obs.unobserve(img);
  }
}).observe(document.querySelector('img.lazy')!);

Open the Accessibility pane in DevTools on an image below the fold: its computed name is empty.

Production-Safe Solution

Prefer native lazy loading, which keeps everything in the markup; use an observer only when you need behaviour native loading lacks (custom margins, placeholders, analytics), and even then keep the accessible attributes static.

HTML
<!-- Best: native, with every accessible attribute present -->
<img src="/img/step-3.jpg" loading="lazy" decoding="async"
     width="1200" height="800"
     alt="Whisking egg whites to stiff peaks in a copper bowl">

<!-- Observer-driven variant: same attributes, source deferred -->
<figure class="step-photo">
  <img class="lazy" data-src="/img/step-3.jpg"
       width="1200" height="800"
       alt="Whisking egg whites to stiff peaks in a copper bowl">
  <noscript><img src="/img/step-3.jpg" width="1200" height="800"
       alt="Whisking egg whites to stiff peaks in a copper bowl"></noscript>
</figure>
TypeScript
export function lazyImages(selector = 'img.lazy'): () => void {
  const io = new IntersectionObserver((entries, obs) => {
    for (const e of entries) {
      if (!e.isIntersecting) continue;
      const img = e.target as HTMLImageElement;
      // Only the source changes; alt and dimensions were there from the start.
      img.addEventListener('error', () => img.classList.add('img-failed'), { once: true });
      img.src = img.dataset.src!;
      obs.unobserve(img);
    }
  }, { rootMargin: '600px 0px' });
  document.querySelectorAll(selector).forEach((el) => io.observe(el));
  return () => io.disconnect();
}
CSS
img.lazy { background: var(--color-surface-muted); }      /* visible placeholder, not an element */
img { max-width: 100%; height: auto; }                    /* keeps the reserved aspect ratio */
img.img-failed { outline: 1px dashed currentColor; }      /* alt text renders inside the box */

The placeholder is a background colour on the image box itself, so there is no extra element to hide from assistive technology. When a load fails, browsers render the alt text inside the reserved box, so the failure still communicates the content.

The Only Thing the Observer ChangesThree boxes. Server-rendered markup already carries alt text, width and height, so the image is described and its space is reserved. The observer, when the image nears the viewport, sets the source attribute and nothing else. The browser then either paints the pixels or, on failure, renders the alt text in the reserved box.Markupalt, width, height presentObserversets src — nothing elseBrowserpaints pixels, or alt text on failure

Writing the Alt Text Itself

Lazy loading does not change what good alt text is, but it often moves alt text into data pipelines — CMS fields, image APIs — where it gets neglected. A few rules for observer-driven galleries and feeds:

  • Describe the content in context. In a recipe step, "Whisking egg whites to stiff peaks" beats "Bowl of egg whites".
  • Use alt="" for decorative images, including purely atmospheric hero photos and repeated thumbnails that duplicate a nearby link's text.
  • Do not repeat the caption. If a <figcaption> already describes the photo, keep the alt short or empty to avoid double announcements.
  • Placeholders are never content. Blur-up previews and dominant-colour boxes should not have their own alt text or their own element in the accessibility tree.
  • Carousel and grid thumbnails that open a larger view should be links or buttons whose accessible name describes the destination, e.g. "Open photo 3 of 12: whisking egg whites".

Edge Cases

Blur-up previews. A tiny inline preview image that is later replaced by the full one is two images to the accessibility tree unless the preview has alt="" or is a CSS background. The robust version applies the preview as background-image on the real <img> element, so there is only one node and one accessible name throughout.

Images inside links. A thumbnail that is the only content of a link provides the link's accessible name through its alt. If that alt arrives late, the link is announced as an unlabelled link — or as its URL — until the image loads. This is the worst case of late alt text and the most common in product grids.

Art direction with <picture>. Defer each <source>'s srcset alongside the <img>'s, but keep alt, width and height on the <img>, which is the only element that carries them. If different sources have different aspect ratios, use CSS aspect-ratio per media query, because the attributes describe only one of them.

Content images disguised as backgrounds. A product photo applied as a CSS background has no alternative text at all. If it conveys content, it should be an <img>; if it must stay a background, give the container role="img" and an aria-label, and treat that label like alt text — present from the start.

One Node, One Name, From First Paint to LoadThree stacked layers describing the same image element over time. At first paint the element has alt text, dimensions and a preview background, and is announced correctly. When the observer fires it gains a source. After load the pixels replace the preview. Its accessible name never changes.First paintalt, width, height and a CSS preview background; announced with its final name.Observer firesOnly src (and srcset) are set; nothing else on the node changes.LoadedPixels replace the preview; the accessible name is unchanged throughout.

Verification Steps

  • Inspect an unloaded image in the Accessibility pane; its name should be the final alt text.
  • Read the page with a screen reader without scrolling (use the images or graphics list shortcut); every image should be described.
  • Record a Performance trace with Layout Shift regions while scrolling; lazy images should never cause a shift.
  • Block image requests in DevTools and confirm alt text renders in reserved boxes.
  • Disable JavaScript and confirm the <noscript> fallback shows the images.

Common Mistakes to Avoid

  • Setting alt in the callback. It will be missing for everyone who has not scrolled there.
  • Omitting width and height. CSS sizing alone often cannot reserve the right aspect ratio before load.
  • Using a separate placeholder <img> without alt="". Screen readers announce two images, one of them meaningless.
  • Lazy-loading the LCP image. The largest above-the-fold image should load eagerly with fetchpriority="high".

FAQ

Do screen readers trigger lazy loading when they reach an image?

Sometimes, if they scroll the page to follow the virtual cursor, but not reliably. That is why an image's description must never depend on it having loaded.

Is native loading="lazy" more accessible than an observer?

It is simpler to get right, because the markup is complete and works without JavaScript. An observer is equally accessible if it only swaps the source and every other attribute is static.

Should the noscript fallback duplicate the alt text?

Yes. When scripting is off, the noscript image is the one that renders and is exposed; the lazy image without a source is not useful on its own.

How do I handle responsive images with srcset?

Defer srcset and sizes the same way as src, via data attributes, and keep alt, width and height static. See the dedicated guide on lazy-loading responsive images for the details of swapping srcset safely.


↑ Back to Accessible Observer-Driven Interfaces