Accessible Skeleton Screens Versus Spinners
Permalink to "Accessible Skeleton Screens Versus Spinners"Skeleton screens and spinners are the two standard ways to say “content is coming”, and they differ in every respect except the one that matters here: both are completely silent to assistive technology. A skeleton is a set of empty boxes with no accessible name; a spinner is a rotating graphic with no role. This page covers what each one is actually good for, how to keep a skeleton out of the accessibility tree, and the status message that has to carry the state regardless of which visual treatment you choose.
It is the visual-treatment detail beneath progressive loading and skeleton states, and the message it depends on is described in announcing async loading states in data tables.
Spec reference
Permalink to "Spec reference"Two criteria apply. SC 4.1.3 Status Messages (Level AA) covers the announcement: a change of state that does not receive focus must be programmatically determinable. SC 2.3.3 Animation from Interactions (Level AAA) covers the shimmer: motion that is not essential should be disabled when the user has asked for reduced motion.
There is no ARIA role for “placeholder”. The correct treatment is to remove the placeholder from the accessibility tree entirely with aria-hidden="true", and to carry the state in a separate role="status" element. aria-busy="true" on the container that will receive the content completes the picture by suppressing reads of a partially built region.
Default behaviour without any of this is a container that a screen reader describes as containing several empty elements — or, if the skeleton rows are tr elements inside the real table, as a table with ten blank rows.
When to use — and when not to
Permalink to "When to use — and when not to"Use a skeleton when the shape of the incoming content is known and stable: a table with a fixed column set, a card grid, a detail panel. The benefit is layout stability, which matters for everyone and matters most to users with low vision using magnification, where a layout shift can move the region they were reading off-screen.
Use a spinner when the shape is unknown or the wait is short. A spinner is one element and costs nothing; a skeleton for content whose height you cannot predict produces a second layout shift when the real content arrives, which is worse than not having one.
Do not use either as the only signal. Whatever the visual treatment, the status message is what makes the state perceivable, and it is the same message in both cases.
Annotated code example
Permalink to "Annotated code example"<!-- SC 4.1.3: the region that actually carries the state -->
<p id="table-status" role="status" class="visually-hidden"></p>
<!-- SC 4.1.2: the skeleton has no name and no role — keep it out of the tree -->
<div class="skeleton" aria-hidden="true">
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
</div>
<!-- aria-busy suppresses partial reads while the real rows are written -->
<table id="results" aria-busy="true" aria-rowcount="0" hidden>…</table>
/* SC 2.3.3: the shimmer stops for readers who asked for less motion */
.skeleton-row {
background: linear-gradient(90deg,
var(--color-surface-subtle) 25%,
var(--color-surface-muted) 37%,
var(--color-surface-subtle) 63%);
background-size: 400% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
@media (prefers-reduced-motion: reduce) {
.skeleton-row { animation: none; }
}
// One message when work starts, one when it settles — nothing in between
status.textContent = 'Loading results';
const rows = await fetchRows();
skeleton.remove();
table.hidden = false;
table.setAttribute('aria-busy', 'false');
requestAnimationFrame(() => {
status.textContent = `${rows.length} results loaded`;
});
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Assistive technology | With a hidden skeleton | With an unhidden skeleton |
|---|---|---|
| NVDA + Firefox | Reads only the status message | Announces the empty placeholder elements as blank content |
| JAWS + Chrome | Reads only the status message | Reports blank rows if the placeholders are tr elements |
| VoiceOver + Safari | Reads the status message after a short delay | Swipes through empty groups with no names |
| TalkBack + Chrome | Reads the status message | Swipe order includes every placeholder |
The right-hand column is what happens when a refactor drops the aria-hidden — a change that produces no visual difference at all, which is why it survives review. A component test asserting the skeleton is hidden costs nothing and prevents it.
Integration context
Permalink to "Integration context"Skeletons are DOM, and DOM is the input to the accessibility tree. A skeleton sized to the viewport is fine; a skeleton sized to the full result set is a self-inflicted DOM size problem that arrives before the data does.
In a virtualized list the two overlap directly: the window renders twenty rows, so the skeleton should render twenty rows, not the total. The count the user is told about comes from aria-rowcount and the status message, never from the number of placeholders.
Gotchas
Permalink to "Gotchas"Skeleton rows inside the real table. Placeholder tr elements in the live tbody are announced as rows even with a shimmer over them, unless the whole block is hidden. Render the skeleton as a separate block and swap it, or hide the individual rows.
The shimmer runs forever after a failure. If the request fails and the skeleton is not removed, the page shows a permanent loading state that announces nothing. Remove the skeleton in the failure path too, and announce the failure assertively.
Reduced motion honoured only for the shimmer. If the skeleton also fades or slides in, disable that under prefers-reduced-motion as well. A single animation: none on one class often misses the others.
Contrast in dark mode. A skeleton built from two grey values tuned for the light theme frequently becomes invisible or glaring in dark mode. Drive both stops from theme tokens.
FAQ
Permalink to "FAQ"Do skeleton screens help screen reader users at all?
Not directly. A skeleton is a set of empty boxes with no accessible name, so it conveys nothing on its own; hidden correctly, it conveys nothing by design. What helps every user, including screen reader users, is the status message that accompanies it. The skeleton earns its place through layout stability and reduced perceived wait for sighted users, and it must not cost anything for anyone else.
Should a spinner have a role and a name?
If it is the only loading indicator, it needs to be paired with a status message rather than named. Giving a spinner role=“img” and aria-label=“Loading” produces a graphic that is announced once when encountered, which is not the same as a status message that reaches the user without focus. Use role=“status” text; keep the spinner aria-hidden.
How many skeleton rows should be rendered?
The number of rows the page will actually show, capped at what fits the viewport. Rendering 40 placeholders for a 20-row page costs DOM and, if the aria-hidden is ever lost in a refactor, costs the accessibility tree too. Ten to twenty rows is enough to communicate the shape of what is coming.
Related
Permalink to "Related"- Progressive loading & skeleton states — the parent guide and the full state machine
- Using aria-busy during progressive table loads — the suppression half
- DOM size limits & performance tradeoffs — why a skeleton needs a budget