Search topics
Skeleton screens that screen readers never announce
Grey placeholder cards stand in for products while they load and are swapped for them in silence, so a screen reader hears neither the wait nor the arrival.
<div class="skeleton line"></div> The problem
Screen reader users find nothing under the heading while the cards load, and are never told when the products arrive
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 divs, 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.
"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.
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.
2 of 10 groups affected
- No vision (affected)
- Low vision (affected)
- Colour vision (not affected)
- No hearing (not affected)
- Hard of hearing (not affected)
- No speech (not affected)
- Motor (not affected)
- Reach, strength (not affected)
- Cognitive (not affected)
- Seizures (not affected)
- Without vision Screen reader users reading the page while it loads hear the heading and then whatever comes after the section, as if it were empty. Nothing tells them the products arrived, so they move on, and meet them only if they happen to go back.
- Limited vision People who use speech alongside a screen magnifier hear nothing about the wait or the arrival either, and zoomed in on another part of the page, they may not see the grey cards turn into products.
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.
Try it
Read the new arrivals while the grey cards show, then wait and listen
Broken 1: press Read the page while the grey cards show. The panel shows what a screen reader reads from the top, then anything it says by itself. Then wait for the products.
Broken 2: the same cards, with aria-busy and aria-live on every placeholder. Read the page again, and wait.
Apply the fix. Read the page while the cards load, then wait. Load again starts over.
Broken Can you fix this?
New arrivals
Free returns within 30 days.
Screen reader says:
(Press Read the page while the grey cards show)
The fix
Say that the cards are loading, and when they arrive
Grey placeholder cards
<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>What is wrong2 points
- The grey cards are empty
divs, 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
divthat is not a live region, so their arrival is never announced.
<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>How it is fixed6 steps
- Add an empty
pwithrole="status"under the heading, outside the grid, so the browser is already watching it when the script writes to it. Thevisually-hiddenclass keeps it out of the design: on screen, the grey cards already show the wait. - 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. - 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. - 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.
- 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.
- Set
aria-busyback tofalseeither 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.
Why this fix: Say it once, when the change has settled
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.
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.
Design ideas
Put the wait into words, and the arrival too
Four ways to draw the New arrivals cards while they load. Each sample loads when it scrolls into view, and Load again runs it again. Three pass because the wait and its end are said in words, once; one writes “Loading…” on the cards and still fails.
In the design file
- Write the loading message and the one that replaces it, word for word, such as “Loading new arrivals…” and “3 new arrivals”.
- Draw the failed load as a sentence that says what happened, with a Try again button where the grey cards were.
- Mark the grey cards as decoration, with no words inside them.
- Write how many times the grey cards pulse and how long each pulse takes, under five seconds in all, and draw them still for a longer wait.
Passes
New arrivals
A loading line above the cards A quiet line under the heading says “Loading new arrivals…”, then “3 new arrivals”, in #4b5563 on white (7.56:1). It is the line a screen reader reads too, so sighted people and screen reader users get the same words, and a slow load no longer looks the same as a stalled one.New arrivals
Cards that fill in one by one Each grey card turns into its product as it arrives, 0.4 seconds apart, fading in over 0.2 seconds, so the page feels quick. The message waits for the last card and is said once, “3 new arrivals loaded.”, not once per card.Rule of thumb: Say it once, when the change has settled
New arrivals
A failed load drawn as a message When the request fails, the grey cards give way to “Couldn’t load new arrivals.” and a Try again button (white on #1d4ed8, 6.70:1), and the same sentence is said once. Grey cards left on screen would tell sighted people the products are still coming, and tell no one else anything.
Fails
New arrivals
Loading…Loading…Loading…“Loading…” written on each cardWCAG 4.1.3 The word is on screen, three times, in #4b5563 on the grey (6.10:1), but it belongs to the cards and goes away with them: a screen reader that comes across it reads it once per card, and nothing says the products arrived. One line outside the cards says the same thing once.
Verify the fix
4 checks, no mouse.
Test with: Screen reader
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.
How screen readers handle role="status" and aria-busy="true"
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, a list that loads more as it scrolls in infinite scroll that moves the footer away as Tab nears it, and a whole new view in single-page app route changes that are never announced. Test your own pages with the screen readers your users use.
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.
EaseWeb service
Fixed it? Keep it from coming back.
I turn guides like this one into rules your coding agents follow and checks your CI runs. Then I retest by hand with a keyboard and screen readers, periodically.