# Skeleton screens that screen readers never announce

Source: https://easeweb.dev/learn/skeleton-screens
Topics: Live updates > Skeleton screens say that content is loading (WCAG 4.1.3)
The fix: Hide the placeholder blocks with aria-hidden="true", and keep a role="status" region on the page from the start: write a loading message into it when the request starts, and the number of items, or an error, once they are all in. aria-busy on the section while it fills may help, but it says nothing on its own.
Test with: screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F103 https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 https://www.w3.org/TR/wai-aria-1.2/#aria-busy https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-busy https://getbootstrap.com/docs/5.3/components/placeholders/

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A homeware shop's home page has a "New arrivals" section. Its products come from a separate request after the page has loaded, and while they are on their way, the grid shows three grey cards: a block where each photo will be, and two bars where the name and price will be. It is the skeleton screen, and most component libraries have one: MUI's Skeleton, shadcn/ui's Skeleton and Bootstrap's placeholders all draw these grey shapes. A second or two later, the products replace the grey cards. Sighted people see the layout at once and watch it fill in.

A screen reader user reads the page from the top while the request is still out. They hear "New arrivals, heading level 2", then "Free returns within 30 days.", the line after the section. The grey cards are empty `div`s, so there is nothing in them to read, and nothing says that products are on their way. When the products arrive, nothing says that either. The person has already taken the section for empty, and unless they happen to go back to the heading, they never meet the new arrivals.

A second version of the shop puts `aria-busy="true"` and `aria-live="polite"` on every placeholder, as React Loading Skeleton does on the `span` it wraps around each one. The code looks as if it announces the wait. It doesn't: the placeholders still hold nothing to read, and when the products arrive, the live regions are removed together with the placeholders, so there is no change in them to announce.

## Why it fails

"Loading new arrivals…" and "3 new arrivals loaded" are status messages in WCAG's sense: they report a waiting state and the result of a process, and they don't take focus. Success Criterion 4.1.3 asks that such messages can be determined through role or properties, so that assistive technology can present them without focus moving. The skeleton shows the waiting state only as grey shapes, with no role or property that marks it as a message, so it carries nothing for someone who can't see them. That is the failure W3C describes in F103. The products that replace the shapes are not a status message themselves, as the Understanding page says of a search's results, but a line such as "3 new arrivals loaded" is one, and the Before has none, so the end of the wait is not put into words either.

The second Before looks closer, but each attribute is on the wrong element. `aria-live` makes an element a live region: a screen reader announces changes to the content inside it while it is on the page. The script never changes what is inside these regions; it removes them, and by default a live region does not report content being removed. `aria-busy="true"` tells assistive technology that an element is being updated and that it may wait until the update is complete before presenting it. It is not a message and does not say "loading", and here it sits on placeholders that are thrown away rather than updated.

## Who is affected

Skeleton screens stand in for the parts of a page that load after it: product grids, feeds, dashboards, search results. Whatever loads that way is the part a screen reader user is most likely to miss.

## What automation and AI miss

A scan that waits for the page to finish loading sees the finished grid and passes it: the skeleton is gone by the time it looks, and an empty `div` breaks no rule anyway. No axe rule looks for skeletons. EaseWeb's audit watches the page as it loads, and when placeholders with a skeleton class appear (a class with "skeleton" in it, as MUI and React Loading Skeleton use, shadcn/ui's `data-slot="skeleton"`, or Bootstrap's `placeholder-glow` and `placeholder-wave`) and the page has no live region outside them, it raises a lead for a person to check. Whether the wait and the arrival are announced still takes someone listening with a screen reader while the page loads. AI assistants asked to make a skeleton accessible may add `aria-busy` or `aria-live` to the placeholder itself, which is the second Before.

## Before: grey placeholder cards

```html
<h2>New arrivals</h2>
<div id="arrivals" class="cards">
  <div class="card">
    <div class="skeleton photo"></div>
    <div class="skeleton line"></div>
    <div class="skeleton line short"></div>
  </div>
  …
</div>
<p>Free returns within 30 days.</p>
```

```js
const products = await fetchArrivals();
arrivals.innerHTML = products.map(card).join('');
```

- The grey cards are empty `div`s, so a screen reader goes from the heading straight to "Free returns within 30 days.", and nothing says the products are loading.
- The products are written into a `div` that is not a live region, so their arrival is never announced.

## Before: placeholders with aria-busy and aria-live

```html
<h2>New arrivals</h2>
<div id="arrivals" class="cards">
  <div class="card">
    <div class="skeleton photo" aria-busy="true" aria-live="polite"></div>
    <div class="skeleton line" aria-busy="true" aria-live="polite"></div>
    <div class="skeleton line short" aria-busy="true" aria-live="polite"></div>
  </div>
  …
</div>
<p>Free returns within 30 days.</p>
```

```js
const products = await fetchArrivals();
arrivals.innerHTML = products.map(card).join('');
```

- The placeholders still hold nothing to read, so the section still sounds empty while it loads, and `aria-busy` is not a message.
- Each live region is removed together with its placeholder, so when the products arrive, nothing in a live region has changed and nothing is announced.

## After: a status line, with the placeholders hidden

```html
<h2>New arrivals</h2>
<p id="arrivals-status" role="status" class="visually-hidden"></p>
<div id="arrivals" class="cards" aria-busy="true">
  <div class="card" aria-hidden="true">
    <div class="skeleton photo"></div>
    <div class="skeleton line"></div>
    <div class="skeleton line short"></div>
  </div>
  …
</div>
<p>Free returns within 30 days.</p>
```

```js
status.textContent = 'Loading new arrivals…';
try {
  const products = await fetchArrivals();
  arrivals.innerHTML = products.map(card).join('');
  status.textContent = `${products.length} new arrivals loaded.`;
} catch {
  arrivals.innerHTML = '';
  status.textContent = 'New arrivals could not be loaded.';
} finally {
  arrivals.setAttribute('aria-busy', 'false');
}
```

1. Add an empty `p` with `role="status"` under the heading, outside the grid, so the browser is already watching it when the script writes to it. The `visually-hidden` class keeps it out of the design: on screen, the grey cards already show the wait.
2. Give each placeholder card `aria-hidden="true"`. The cards have nothing to say, and any live or busy attributes a library puts on its placeholders go quiet with them.
3. Mark the grid `aria-busy="true"` while it fills. A screen reader that honours it can hold back a half-built grid; it says nothing by itself.
4. When the request starts, write "Loading new arrivals…" into the status line. The screen reader reads it once it has finished what it is saying, and reading the section now gives the heading, then that line.
5. Once every product is in the grid, write the count: "3 new arrivals loaded." If the request fails, clear the grey cards and write "New arrivals could not be loaded." instead.
6. Set `aria-busy` back to `false` either way.

This fixes both Befores and passes 4.1.3: the wait and the arrival are each put into words in a status region, without focus moving. The message is written once, when the grid is complete, not once per card.

## Implementation decisions

**Hide the placeholders, don't name them.** Giving each grey card `role="img"` and an `aria-label` of "Loading", or its own `role="status"`, has a screen reader read the same word once per card, and a grid of twelve cards says it twelve times. The message is about the section, so it goes in one place. Bootstrap's own placeholder example marks the grey card `aria-hidden="true"`, and its documentation says some JavaScript may be needed to swap the placeholder for the content and tell assistive technology about the update.

**One message, when the last item is in.** If the cards arrive one at a time, from several requests or a stream, write the count after the last one. Don't put `aria-live` on the grid itself: every product's name and price would be read as its card is added, a run of fragments rather than one message.

**`aria-busy` is optional.** ARIA lets assistive technology wait while an element is busy, but screen readers don't all honour it, so the fix works without it. If you use it, set it back to `false` on every way out, failure included: a screen reader that honours it may keep a grid that stays busy from its user.

**The message can be visible.** The same status line without `visually-hidden`, such as "Loading new arrivals…" under the heading and then "3 new arrivals", tells everyone what is happening, which helps when a load is slow: grey cards look the same whether the request is a second away or has stalled.

**Don't leave the grey cards up.** A request that fails or takes very long should end in words on screen, not in placeholders that stay. Let a pulse or a shimmer run a few times, under five seconds in all, and then hold the cards still; one that keeps running is its own failure, covered in [pulsing buttons, glowing badges and endless animations](/learn/endless-animations).

**Keep the region out of the swap.** In a component framework, the skeleton and the content are often two branches of one condition (`loading ? <Skeleton /> : <Grid />`). Keep the status line outside both, rendered with the page, or it arrives with its message and is often not read.

**Don't move focus.** The products arrive while people may be reading something else; pulling focus into the grid takes them away from it. When a button starts the load, such as a filter, the message belongs beside that button, as in [loading spinners that screen readers never announce](/learn/loading-states).

## Verify the fix

1. Slow the network in developer tools and inspect the markup while the grey cards show: the placeholders, or what holds them, have `aria-hidden="true"`, and an empty `role="status"` element sits outside them.
2. With a screen reader, reload the page and read the section while the grey cards show: you hear the heading and "Loading new arrivals…", and no blank or unnamed items.
3. Stay where you are and wait: "3 new arrivals loaded." is read once, focus does not move, and reading the section again now gives the products.
4. Slow the network further, then block the request: the loading message is not repeated while you wait, the grey cards go when it fails, the error is read, and `aria-busy` is `false` afterwards.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the loading component or the data layer changes.

## Limits

Live regions and `aria-busy` behave differently across screen readers and browsers. A message written while the page itself is still loading may be read late, or not at all, if the screen reader starts reading the page at the same moment; the status line stays in the reading order, so someone who reaches the section later still finds "Loading new arrivals…" or the count. This guide covers placeholders that stand in for one section on one page. A spinner or a loading message after a button press is covered in [loading spinners that screen readers never announce](/learn/loading-states), a list that loads more as it scrolls in [infinite scroll that moves the footer away as Tab nears it](/learn/infinite-scroll), and a whole new view in [single-page app route changes that are never announced](/learn/route-changes). Test your own pages with the screen readers your users use.

## What screen readers do (tested by hand by EaseWeb)

`role="status"`: A polite live region: its changes wait until the screen reader has finished what it is saying.

| Screen reader | Browser and system | Down Arrow | Say all |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

`aria-busy="true"`: Marks a part of the page as being updated, so assistive technology can wait for the update to finish.

| Screen reader | Browser and system | Down Arrow |
| --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested |
| NVDA | Chrome, Windows 11 | Not tested |
| JAWS | Edge, Windows 11 | Not tested |
| NVDA | Firefox, Windows 11 | Not tested |
| NVDA | Edge, Windows 11 | Not tested |
| JAWS | Firefox, Windows 11 | Not tested |
| VoiceOver | Safari, macOS | Not tested |
| VoiceOver | Safari, iOS | Not tested |
| TalkBack | Chrome, Android | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
