MonkeyLoader All articles
Performance

The Spinner Says Nothing: Announcing Busy States Without a Wall of Noise

MonkeyLoader
The Spinner Says Nothing: Announcing Busy States Without a Wall of Noise

Watch a loading spinner for ten seconds with your eyes shut. That is roughly the experience a screen reader user has on plenty of UK council forms and SaaS dashboards: silence, then a page that has quietly changed underneath them. The animation is doing all the work, and it is aimed at the wrong sense.

The fix is not to bolt aria-live onto everything. That swaps silence for a gobby interface that reads out every filter keystroke and every percentage tick. The job is to pick one quiet place for the interface to speak, say only what matters, and shut up the rest of the time.

What the screen reader actually hears

Live regions are the mechanism. A container with aria-live="polite" (or role="status", which implies it) gets its text changes queued and read when the user is idle. It does not move focus. It does not interrupt. In some browser and reader pairings it will not fire if the region was hidden with display: none when the page loaded, or if you swap the whole node out mid-flight.

Two rules fall out of that. First, put the region in the initial HTML, visually hidden but present. Second, change its text content; do not replace the element.

<div id="load-status" role="status" aria-live="polite" aria-atomic="true" class="visually-hidden"></div>

aria-atomic="true" makes the reader announce the whole region rather than just the diff, which matters when you go from "Loading your balance" to "Your balance is ready".

Choose one mouth, not five

The usual failure is a live region per component: the search box has one, the table has one, the pagination has another. On a busy dashboard three of them fire within 400ms and NVDA reads you a sentence salad.

Pick a single status region per view, owned by the page, and let components push messages into it. If you genuinely need two, split them by job: one for background sync, one for the user's current action. Anything beyond that is noise with a polite accent.

Say the thing, not the animation

"Loading" is not information. "Loading your council tax balance" is. "Loading 40 of 220 rows" is better again, but only if you are going to finish the sentence.

Announce the start and the end. A region that says "Loading…" and then goes blank leaves the user wondering whether the thing worked or died. Set it to "Balance loaded" or "No balance found" and leave it there until the next action.

For search and filter interfaces, lead with the result: "24 results for blue badge". That one sentence replaces a spinner, a count badge and a hopeful cough.

Debounce, or say nothing at all

Fast operations do not need announcing. A local filter that resolves in 80ms should just resolve. Set a timer — 800ms to 1.5s is a fair range — and only populate the live region if the work is still running when it expires. If it finishes first, clear the timer and stay quiet. Users do not need a running commentary on your cache.

Progress bars are the other trap. Do not wire aria-valuenow to a live region and update it every percent. Announce a start, maybe a midpoint if the job is genuinely long, and the end. If the bar is a file upload, name the file once; nobody needs "47 per cent" read back to them.

The busy flag, used sparingly

aria-busy="true" tells assistive tech that a container is mid-update and that it should hold off announcing until you are done. It is genuinely useful on a results region that is about to be rebuilt. It is not a substitute for a status message, and support is patchy — some reader and browser pairs will suppress everything inside a busy region, including your carefully worded announcement, until you flip it back. If you use it, set it on the container being updated, not on the live region itself, and test the pairing you actually support.

Failure deserves a sentence too

Timeouts and 500s are where accessible loading states fall apart. A spinner that stops and leaves an empty table tells nobody anything. Use role="alert" for real errors — it interrupts, which is right for "Your session has expired" — and keep role="status" for the mundane. Do not put the same message in both; screen readers will read it twice and users will assume the page is broken.

Reduced motion is not just about motion

prefers-reduced-motion should kill the endless spin, but it should not kill the feedback. Swap the rotating graphic for a static label and keep the status region working. For long jobs, add a line after ten seconds — "Still working, this can take up to a minute" — and a cancel button if you can actually honour it. WCAG 2.2's 2.2.2 is about time limits, but the spirit is the same: do not leave people trapped in an indefinite wait with no information.

Testing that beats a checklist

Automated scanners will not catch a live region that announces the wrong thing, or one that announces three times. You need ears.

Record what was spoken, not just whether the test passed. "Announced 'Loading' twice, never announced completion" is a bug report someone can fix.

What we would ship

One hidden status region per view. Announce only waits over about a second. Announce the outcome, including failures. Keep aria-busy off unless you have tested it. Respect reduced motion without removing the text. Then sit with a screen reader for ten minutes and listen to your own checkout — it is the least flattering ten minutes in web development, and the most useful.

The Public Sector Bodies Accessibility Regulations 2018 and WCAG 2.2 SC 4.1.3 give you a reason to do this properly. The better reason is that a spinner, on its own, is a promise made in a language some of your users cannot hear.

All Articles

Related Articles

Postcode First, Pixels Later: A Store Locator That Doesn't Drag the Map Along

Postcode First, Pixels Later: A Store Locator That Doesn't Drag the Map Along

Leave the Diary Blank Until They Click: A Booking Widget That Earns Its Place

Leave the Diary Blank Until They Click: A Booking Widget That Earns Its Place

Assume the Signal Will Drop: A Loader Built for the Morning Commute

Assume the Signal Will Drop: A Loader Built for the Morning Commute