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.

The four states of a progressive load Timeline of a progressive table load: idle, requested, partially hydrated and settled, with the announcement each state owes the user. The four states of a progressive loadIdleno region contentRequested"Loading results" — onceHydratingaria-busy="true", silentSettled"412 results loaded"time across one load →
Only two of the four states should produce speech.

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"

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
aria-busy, role=status and aria-live compared Matrix comparing aria-busy, role=status and a progress element across what each one suppresses, announces and requires. aria-busy, role=status and aria-live comparedWhat it doesWhat it does NOT doaria-busy="true"suppresses announcements while a region isbeing writtentell the user anything at allrole="status"announces a message politely without movingfocussuppress the noise of a partially builttable<progress>exposes a determinate value the user canqueryannounce spontaneously — it must be read onfocus
They solve different halves of the same problem and are not interchangeable.

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; }
}
Skeleton rows and the accessibility tree Comparison of skeleton placeholders that stay out of the accessibility tree against placeholders that are announced as if they were data. Skeleton rows and the accessibility tree✓ Correctaria-hidden="true" on the placeholder blockaria-busy="true" on the container while itfillsOne polite "Loading results" message✗ BrokenTen empty rows announced as ten rows of dataA spinner with no accessible name and nomessageA message repeated once per skeleton row
A skeleton is a visual promise, not content.

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
}
Streaming rows into a table without flooding speech Three-step approach to streamed hydration: mark the container busy, append batches without announcing, then clear busy and announce the final count. Streaming rows into a table without flooding speechMark busyaria-busy="true" on the tablePartial reads are suppressed from here onAppend in batchesinsert 50 rows per frame, no announcementsKeeps the main thread and the speech queue quietSettle and announcearia-busy="false"; "412 rows loaded"One message once the stream completes or pauses
Announce the batch, never the row.

Keyboard interaction contract

Permalink to "Keyboard interaction contract"
What must stay operable during a load Keyboard contract for the loading state: focus must survive, controls must not disappear, and Escape must cancel where cancellation exists. What must stay operable during a loadTabContinue moving through the pageControls must not be removed mid-load, onlydisabled with aria-disabledEscapeCancel the request where cancelling issupportedAnnounce "Loading cancelled"FocusStays exactly where the user left itNever move focus into a skeleton or a spinnerEnterRetry after a failureThe retry control must be reachable without hunting
A loading table that steals focus is worse than a slow one.

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.

Perceived wait versus announced wait Bar chart comparing how long each loading treatment leaves a screen reader user with no information, measured in seconds for a two second fetch. Perceived wait versus announced waitSpinner only, no message20 (tenths of a second silent)Skeleton rows, no message20 (tenths of a second silent)Status message on request2 (tenths of a second silent)Status message plus settled count0 (tenths of a second silent)
A spinner with no message is a silent two seconds; a status message makes the same wait legible.

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.

Permalink to "Related"

Back to Virtualization, Charts & Dynamic Data Displays