Progressive Loading & Skeleton States
Permalink to "Progressive Loading & Skeleton States"Every data view spends part of its life incomplete. It is fetching, it is hydrating, it is streaming rows in from a paged endpoint, or it has failed and is waiting for a retry. Sighted users read all of this from motion: a shimmer where the rows will be, a spinner in the corner, a count that appears when the work finishes. None of that reaches assistive technology by itself. A skeleton screen is a set of empty boxes; a spinner is a rotating graphic with no accessible name; a partially hydrated table is a table that currently has four rows in it.
This page covers the state machine of a progressive load and the three tools that make it legible: aria-busy to suppress the noise of a half-built region, a polite status region to say what is happening, and an assertive channel for the failure case. It is written for engineers building tables and charts that fetch, and it assumes the announcement mechanics from screen reader announcement strategies and announcing async loading states in data tables.
WCAG criteria in scope
Permalink to "WCAG criteria in scope"| Criterion | Level | Why it applies here |
|---|---|---|
| 4.1.3 Status Messages | AA | Loading, loaded and failed states must reach assistive technology without focus |
| 2.2.1 Timing Adjustable | A | An auto-refreshing view that reloads on a timer needs a pause control |
| 2.3.3 Animation from Interactions | AAA | Shimmer animations should respect prefers-reduced-motion |
| 1.4.13 Content on Hover or Focus | AA | Loading tooltips must be dismissible and must not obscure the control |
| 2.4.3 Focus Order | A | A load must not move focus, and must not remove the element that has it |
| 4.1.2 Name, Role, Value | A | A spinner used as the only loading signal needs a name and a role, or it needs a message instead |
The criterion that catches most teams is 2.4.3. A common implementation replaces the table’s container contents with a skeleton while loading. If the user had focus on a row, that focus is destroyed and lands on document.body, which throws the screen reader back to the top of the page — during a load, when they have no way to tell what happened.
Prerequisites
Permalink to "Prerequisites"- Announcing async loading states in data tables — the message half of this page, in detail.
- ARIA live regions for dynamic data — where those messages go.
- Accessible virtualized list patterns — streamed hydration and windowing interact.
- Real-time data stream announcements — the same throttling discipline applies to a stream that never finishes loading.
- Pagination & result-set navigation — every page change is a load.
ARIA & HTML spec reference
Permalink to "ARIA & HTML spec reference"| Attribute / element | Valid values | Apply it to | Common misuse |
|---|---|---|---|
aria-busy |
true / false |
The container being written | Leaving it true after the load settles, which silences later updates |
role="status" |
— | The message region | Creating the region and its text in the same tick, so nothing is announced |
role="alert" |
— | The failure message | Using it for routine loading, which interrupts the user constantly |
aria-hidden |
true |
Skeleton placeholder blocks | Applying it to the real container instead of the placeholder |
<progress> |
value, max |
A determinate long-running import | Expecting it to announce spontaneously — it does not |
aria-live |
polite |
The status region | Putting it on the table, which announces every inserted row |
Step-by-step implementation
Permalink to "Step-by-step implementation"Step 1 — Keep the region in the DOM and mark it busy (SC 4.1.3)
Permalink to "Step 1 — Keep the region in the DOM and mark it busy (SC 4.1.3)"<!-- SC 4.1.3: the status region exists at page load so the reader has registered it -->
<div id="table-status" role="status" class="sr-only"></div>
<!-- aria-busy suppresses partial reads while rows are written -->
<table id="results" aria-busy="false" aria-rowcount="0">
<caption>Transactions</caption>
<thead><tr><th scope="col">Date</th><th scope="col">Amount</th></tr></thead>
<tbody></tbody>
</table>
Step 2 — Announce the request, not the render (SC 4.1.3)
Permalink to "Step 2 — Announce the request, not the render (SC 4.1.3)"// SC 4.1.3: one message when work starts, one when it settles — nothing in between
const table = document.getElementById('results');
const status = document.getElementById('table-status');
async function load(query) {
table.setAttribute('aria-busy', 'true');
status.textContent = 'Loading results'; // announced once
try {
const rows = await fetchRows(query);
renderRows(rows); // may take several frames
table.setAttribute('aria-rowcount', String(rows.length));
table.setAttribute('aria-busy', 'false'); // partial reads are over
status.textContent = `${rows.length} results loaded`;
} catch (err) {
table.setAttribute('aria-busy', 'false');
alertRegion.textContent = 'Could not load results. Retry, or change the filters.';
}
}
Step 3 — Hide the skeleton from assistive technology (SC 4.1.2)
Permalink to "Step 3 — Hide the skeleton from assistive technology (SC 4.1.2)"<!-- SC 4.1.2: a skeleton has no name and no role — keep it out of the tree entirely -->
<div class="skeleton" aria-hidden="true">
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
<div class="skeleton-row"></div>
</div>
/* SC 2.3.3: the shimmer stops for readers who ask for less motion */
@media (prefers-reduced-motion: reduce) {
.skeleton-row { animation: none; }
}
Step 4 — Stream in batches, announce at the end (SC 4.1.3)
Permalink to "Step 4 — Stream in batches, announce at the end (SC 4.1.3)"// Batch inserts keep the main thread responsive and the speech queue quiet
async function hydrate(stream) {
table.setAttribute('aria-busy', 'true');
let total = 0;
for await (const batch of stream) { // 50 rows at a time
appendRows(batch); // no announcement per batch
total += batch.length;
}
table.setAttribute('aria-busy', 'false');
status.textContent = `${total} rows loaded`; // exactly one message
}
Keyboard interaction contract
Permalink to "Keyboard interaction contract"Screen reader compatibility matrix
Permalink to "Screen reader compatibility matrix"| Assistive technology | Behaviour during aria-busy |
Known deviation |
|---|---|---|
| NVDA + Firefox | Suppresses live-region updates in the busy subtree | Resumes immediately when busy flips to false |
| JAWS + Chrome | Suppresses updates; reads the status message on settle | May read a stale count if the region is written before aria-busy clears |
| VoiceOver + Safari | Honours aria-busy; delays the settle message slightly |
Drops the settle message if it is written in the same frame aria-busy clears |
| TalkBack + Chrome | Honours aria-busy |
The <progress> value is announced only when focused |
The VoiceOver row explains a bug that is common and hard to find: clearing aria-busy and writing the message in the same tick means VoiceOver sees the message arrive while the region is still marked busy and discards it. Clear aria-busy, wait one frame, then write.
Edge cases & failure modes
Permalink to "Edge cases & failure modes"A load that never resolves. A request that hangs leaves the user in a state that was announced once and never updated. Set a ceiling — ten to fifteen seconds — and announce the timeout with a retry: “Still loading. Retry, or reduce the date range.”
A refresh triggered by a timer. A dashboard that reloads every 30 seconds is automatically updating content under SC 2.2.1 and needs a pause control. It also needs to not announce every refresh; announce only when the data actually changed.
Focus inside a region that reloads. If focus is inside the table when a reload begins, do not replace the container. Keep the focused row mounted, or move focus to the table container first and announce the reload.
Optimistic rows. An optimistically inserted row that is later rejected must be announced when it is removed — otherwise the user has a row in their mental model that no longer exists.
Retry storms. A retry control that fires a new request on every press queues one announcement per press. Disable it with aria-disabled while a retry is in flight and announce only the outcome.
Skeleton screens versus spinners
Permalink to "Skeleton screens versus spinners"The two treatments look different and sound identical — which is to say, silent. The detail page covers when each is the better visual choice and what both owe the accessibility tree: accessible skeleton screens versus spinners.
Using aria-busy during progressive loads
Permalink to "Using aria-busy during progressive loads"aria-busy is the most misunderstood attribute in this area: it is a suppressor, not an announcer, and leaving it on silences everything that follows. The detail page covers the lifecycle and the VoiceOver timing trap: using aria-busy during progressive table loads.
Announcing streamed rows as data hydrates
Permalink to "Announcing streamed rows as data hydrates"A stream that fills a table over several seconds needs a batching strategy for both rendering and speech. The detail page covers batch sizes, the settle message and what to do when the stream never ends: announcing streamed rows as data hydrates.
Design system integration notes
Permalink to "Design system integration notes"Loading state is a component-level concern that teams usually solve page by page, which is why it drifts. A design system can settle it once. Three tokens and one contract are enough: a skeleton surface token that already meets contrast in both themes, a shimmer animation that is disabled under prefers-reduced-motion, and a status-region component that every data component mounts by default rather than by choice.
The contract is the important half. Any component that fetches should accept a loadingLabel and a loadedLabel, both required, both plain sentences. Making them required props forces the announcement decision at build time instead of leaving it to whoever notices during an audit. Components that stream should also expose an onSettled callback so the consuming view can write its own count message when it knows more about the data than the component does.
Map the states to your component variants explicitly: idle, loading, partial, settled and failed. Each variant maps to a specific combination of aria-busy, a message and a focusable retry. When the variants are enumerated in the component API, an automated test can assert that every variant produces the right attributes, which turns a whole class of accessibility regressions into a unit test.
Finally, budget the skeleton. A skeleton that renders 40 placeholder rows for a 20-row page inflates the DOM for no benefit and, if anyone forgets the aria-hidden, inflates the accessibility tree too. Render the number of rows the page will actually show, and let DOM size limits and performance tradeoffs set the ceiling.
Why the loading state is where regressions hide
Permalink to "Why the loading state is where regressions hide"Loading state is uniquely prone to silent regression for three reasons, and knowing them tells you where to point your tests.
First, it is transient. A missing announcement exists for two seconds and then the page looks correct, so a reviewer clicking through the interface sees nothing wrong. Nobody screenshots a loading state.
Second, it is usually implemented far from the component that displays it. The fetch lives in a data layer, the skeleton in a presentational component, and the status message — if it exists — in a third place. A refactor that moves the fetch rarely moves the announcement with it, and nothing breaks visibly.
Third, it is environment-dependent. On a fast local connection the loading state may never render at all, so a developer can work on the component for weeks without seeing the state they are responsible for making accessible. Throttling the network in DevTools to a slow connection is the single most useful habit here: it makes the invisible state visible, long enough to check that it announces.
The countermeasure is to assert on the state rather than the appearance. A component test that renders the loading variant and asserts aria-busy="true" plus a non-empty status message costs almost nothing and catches every one of the three failures above.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Are skeleton screens better than spinners for accessibility?
Visually, yes — they reduce perceived wait and prevent layout shift, which helps everyone including users with cognitive and vestibular considerations. For assistive technology they are neutral: a skeleton conveys nothing unless it is paired with a status message. Mark the skeleton aria-hidden, keep the container aria-busy, and let a single polite message carry the state. The skeleton and the spinner are equally silent without it.
When should aria-busy be used instead of just a status message?
Use aria-busy when the region will be written in several steps and a partial read would be misleading — a table hydrating in batches, a chart whose axes render before its series. It suppresses the intermediate noise. It never replaces the status message, because aria-busy says nothing to the user; it only stops the reader from describing a half-built region.
How should a failed load be announced?
Assertively, and with a way forward. A failure is exactly the case WCAG 4.1.3 and the assertive politeness level exist for: the user will otherwise wait indefinitely for content that is never coming. “Could not load results. Retry, or change the filters.” Put the retry control immediately after the message in the DOM so the user reaches it on the next tab.
Related
Permalink to "Related"- Accessible virtualized list patterns — hydration and windowing in the same view
- Real-time data stream announcements — loads that never finish
- Announcing async loading states in data tables — the message copy in detail
- DOM size limits & performance tradeoffs — why skeletons need a budget too
- Pagination & result-set navigation — every page change is a load