# Loading spinners that screen readers never announce

Source: https://easeweb.dev/learn/loading-states
Topics: Live updates > Loading is announced (WCAG 4.1.3)
The fix: Put the loading and finished messages in a live region that is on the page from the start. Either use role="status", which waits for the screen reader to finish speaking, or role="alert", which interrupts it and is best kept for urgent news. Announce the start, the result and any error.
Test with: screen reader, keyboard
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/alert_role

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

On an account page, a "Show orders" button fetches past orders. A spinner turns and the line "Loading orders…" appears under the button. A second later the spinner goes and three orders appear. A sighted user follows every step. A screen reader user presses the button and hears nothing: not the start, not the end. Focus is still on the button, so they press it again, or move on and miss the orders.

## Why it fails

"Loading orders…" and "3 orders loaded" are status messages: they report the progress and result of an action without moving focus. Success Criterion 4.1.3 asks that such messages can be presented to assistive technology without receiving focus. Here the text is added to a plain `div`, so the change is visible but never announced. The spinner is a styled `span` with no text at all.

## Who is affected

The spinner is drawn for people who are looking at it, so anyone who isn't gets no sign that anything happened.

## What automation and AI miss

A scan of the loaded page sees a list of orders and passes it; it never sees the busy state in between. Many scanners can't tell a status message from any other text. AI code often adds `aria-live` to the element that the script creates with its text already inside, and many screen readers ignore a region that arrives with its content. Others add `aria-busy` and expect it to say "loading", which it doesn't. Only a screen reader, used during the load, shows whether anything is said.

## Before: a spinner and text in a plain container

```html
<button type="button" id="show-orders">Show orders</button>
<div id="orders-state"></div>
<ul id="orders"></ul>
```

```js
button.addEventListener('click', async () => {
  state.innerHTML = '<span class="spinner"></span> Loading orders…';
  const orders = await fetchOrders();
  state.innerHTML = '';
  renderOrders(orders);
});
```

The text appears and disappears on screen, but nothing tells assistive technology it changed, and the result is never put into words at all.

## After: a status region that waits its turn

```html
<button type="button" id="show-orders">Show orders</button>
<p id="orders-state" role="status"></p>
<ul id="orders"></ul>
```

```js
button.addEventListener('click', async () => {
  state.textContent = 'Loading orders…';
  try {
    const orders = await fetchOrders();
    renderOrders(orders);
    state.textContent = `${orders.length} orders loaded.`;
  } catch {
    state.textContent = 'Orders could not be loaded. Try again.';
  }
});
```

The `p` with `role="status"` is in the HTML from the start and empty, so the browser is already watching it when the text changes. `role="status"` is a polite live region: the screen reader finishes what it is saying, then reads "Loading orders…", and later "3 orders loaded." Focus stays on the button. Draw the spinner with CSS on the region or mark it `aria-hidden="true"`; the words carry the meaning.

## After: an alert region that interrupts

```html
<button type="button" id="show-orders">Show orders</button>
<p id="orders-state" role="alert"></p>
<ul id="orders"></ul>
```

The script is the same as for the status region. `role="alert"` is an assertive live region: the screen reader stops what it is reading and speaks the message at once. It passes 4.1.3 too, but every routine load now cuts into whatever the person was listening to. It suits loads that end in something people must act on, such as a session that is about to time out, not a list of orders.

## Which option to use

Both regions announce the loading text and the result without moving focus. Use the status region: loading and loaded are routine news, and `role="status"` delivers them without cutting anyone off, for the same code. The alert region is right only when the news can't wait. If both kinds of message come from the same action, use one region of each: routine progress in the status region, the failure in the alert region.

## Implementation decisions

**The region must exist first.** Render it empty with the page and change only its text. A region added together with its message is often not announced. In a component framework, keep the region outside any block that mounts with the loading state.

**The role is the live region.** `role="status"` already means `aria-live="polite"` and `aria-atomic="true"`, and `role="alert"` means `aria-live="assertive"`, so neither needs an `aria-live` attribute beside it. Some teams add a matching `aria-live` as a fallback for very old screen readers; it does no harm, but never pair a role with the other politeness, as in `role="status" aria-live="assertive"`. A plain `<div aria-live="polite" aria-atomic="true">` with no role works too; without `aria-atomic`, some screen readers read only the part of the message that changed.

**Say it once.** Don't write a percentage into the region on every tick; people hear a stream of numbers. Announce the start, maybe a slow-load update after a few seconds ("Still loading…"), then the end. For a visible progress bar, use `<progress>` beside the region, not in it.

**Short loads.** If the result arrives within a fraction of a second, you can skip "Loading…" and announce only the result. If the same text is written twice in a row, some screen readers stay silent; clear the region first, or vary the message ("3 orders loaded" then "Orders refreshed").

**Not fixes on their own.** `aria-busy="true"` tells assistive technology to wait before reading a region; it does not say "loading". Moving focus to the new content works for a page-like change, but it can't announce the busy state and it takes people away from where they were. The `<output>` element maps to the status role, but screen readers announce its changes less reliably than an explicit `role="status"`.

**Errors.** A failed request needs words too. Put it in the same status region, or in an alert region if the person must act now.

## Verify the fix

1. Inspect the DOM before any request: the live region is present and empty, with `role="status"` (or `role="alert"`), and the spinner is hidden from assistive technology.
2. With a screen reader on, activate the control from the keyboard: the loading message is announced once, and focus stays on the control.
3. Let the load finish, then make it fail (block the request in developer tools): the result, with a count, and the error are each announced in words.
4. Repeat the action and slow the network down: a second load is announced again, a slow load does not repeat progress on every tick, and the status messages wait until other speech has finished.

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

## Limits

Live regions behave differently across screen readers and browsers, especially for regions that are hidden, moved, or inside dialogs. This pattern covers one loading action on one page. Test your real flows, including route changes and infinite scroll, with the screen readers your users use.
