User Experience (UX)
Now let's look at how a spinner behaves for every person and setting. A good spinner is accessible, predictable, designed in every state and adaptive.
Accessible
Keep it out of the accessibility tree
The Spinner is hidden with aria-hidden="true", and has no role and no name. A turning circle means nothing to a screen reader. With role="img" and a name like "Loading", screen readers would read it out as an image when people came across it, and say nothing at all when loading starts or ends.
Say what's loading
Since the Spinner doesn't say anything, whatever shows it has to. Screen readers only announce changes to a live region that's already in the page, so there are two ways to do it:
- A component that shows a spinner announces a label of its own, like the Button's
loadingLabel, through a live region it prepared when it mounted. - Anywhere else, put the spinner next to text that says what's loading, inside a status message that's always in the page. People can see the text, and screen readers announce it.
import { useState } from "react";
export function CheckForUpdates() {
const [checking, setChecking] = useState(false);
const [status, setStatus] = useState("");
async function check() {
setChecking(true);
setStatus("Checking for updates");
try {
setStatus((await api.checkForUpdates()) ? "An update is available" : "You're up to date");
} catch {
setStatus("Couldn't check for updates. Try again.");
} finally {
setChecking(false);
}
}
return (
<>
<Button variant="outline" disabled={checking} focusableWhenDisabled onClick={check}>
Check for updates
</Button>
<p role="status">
{checking && <Spinner />} {status}
</p>
</>
);
}
- The status message is in the page from the start, empty, so screen readers announce "Checking for updates" as soon as it appears, and then the outcome (WCAG 4.1.3).
- The text is what people read, and what screen readers and agents find, so the Spinner can stay silent.
- The spinner goes with the message here, since the message says more than the button's label would. The Button is disabled while it checks, and keeps its focus thanks to
focusableWhenDisabled. When the button's own label says enough, itsloadingstate does all of this for you, spinner and announcement included.
Keep focus where it is
Never show a spinner in place of the element that has focus. Think of a "Load more" button that turns into a spinner when you press it. The button leaves the page, focus falls back to the <body>, and keyboard and screen reader users lose their place. Show the spinner next to the button instead, or give the button loading, which keeps its focus.
Contrast
The indicator is what tells people that something's happening, so it needs a contrast of 3:1 against what's behind it (WCAG 1.4.11). It's drawn in the colour of the text around it, so wherever that text passes 4.5:1, the indicator passes too. The track only shows the path, so it doesn't need to. With reduced motion, the pulse fades the whole spinner to 40% and back, so it only reaches that contrast at the brighter end of each pulse.
Predictable
Use it for waits of unknown length
A spinner says that something's happening, but not how long it'll take. That's fine for a few seconds, and frustrating for much longer. We recommend:
| When the wait is… | Show |
|---|---|
| Under a second | Nothing. A spinner that flashes on and off distracts more than it helps |
| A few seconds, of unknown length | A Spinner, where the thing that's loading is |
| Longer than 10 seconds, or of a length you can measure | A progress bar, like <progress>, that shows how much is done |
These follow the guidance from Nielsen Norman Group, which recommends no indicator at all for waits under a second, and reserves looped animations, like a spinner, for waits of 2 to 10 seconds.
One spinner per wait
Show one spinner for each thing that's loading, right where it's loading. A loading Button already shows one, so the page doesn't need another, and a list that loads its rows shows one for the list, rather than one for every row.
Don't move the page
A spinner shouldn't push anything around when it appears or disappears, because content that moves is easy to lose your place in, and a target that moves under your pointer is easy to miss. The Spinner does its part: it's a square of a fixed size, and it turns with rotate, which doesn't take up any more room. The rest is up to where you put it, so give it room that's already there. The Button lays its spinner over its content, and the status message in the example above is in the page from the start, wide enough for every message.
Every state
The Spinner has no states of its own, since it's how other components show theirs. What it shows is a wait, though, and every wait goes through the same few states, which are up to the page to design.
Busy
Something's loading. Show the spinner where it's loading, next to text that says what, and leave everything else where it was. Whatever started the wait keeps its focus and can't start it again, which a loading Button takes care of on its own.
Done
Take the spinner out as soon as the wait ends, and say how it went in the same status message, as in "You're up to date" or "12 results". Leave focus where it was, unless the wait was for something people go straight to, like the next step of a form, in which case move focus to its start.
Failed
Take the spinner out here as well, and say what went wrong and what people can do about it, as in "Couldn't check for updates. Try again." A status message is polite, so screen readers wait until they've finished what they're reading, which suits most errors. For one that needs attention straight away, use role="alert" instead. Never leave the spinner turning after a failure, because there's nothing left to wait for, and the page looks as if it has frozen.
Taking longer than expected
When a wait runs past 10 seconds, a spinner on its own stops reassuring people. Update the message to say what's going on, like "Still checking. This can take up to a minute.", and offer a way to cancel if you can. Change the message rarely, though. Screen readers read out every change, so a counter that ticks every second would never stop talking.
Adaptive
Motion
When someone prefers reduced motion (prefers-reduced-motion: reduce), the spinner stops turning (WCAG 2.3.3), but it still needs to show that something's happening, since a still circle looks as if the page has frozen. We recommend a pulse, which changes the spinner's opacity without moving it, and ours fades to 40% and back every 1.6 seconds.
More contrast
When someone asks for more contrast (prefers-contrast: more), nothing needs to change. The indicator is already drawn in the text's own colour at full strength, and the track only shows the path.
Forced colours
Windows contrast themes replace every colour with the user's own. The Spinner is drawn in currentColor, so it takes the system's text colour along with the text around it, and stays visible without any styles of its own. In a disabled or loading Button, that's GrayText.
Colour scheme
The Spinner has no colours of its own, so it follows your light and dark themes along with the text around it.
Text size
It's sized in em, so it grows with the text around it at 200% text size (WCAG 1.4.4), and stays as big as the text it sits next to. Components that size it in rem, like the Button, grow it along with everything else.
Languages and direction
The Spinner has no text of its own, so there's nothing in it to translate. The text that says what's loading belongs to the page, so remember to translate it along with the rest, and the Button's loadingLabel too. The spinner turns clockwise in every language, the way a clock does, so there's nothing to mirror in right-to-left layouts either.
UX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
spinner/decorative | Must | Component | It's hidden from assistive technologies with aria-hidden, and has no role or name of its own | WCAG 1.1.1 (A) | Unit test |
spinner/say-what-loads | Must | Usage | Whatever shows it says what's loading, in a status message that's already in the page or with a label it announces, like the Button's loadingLabel | WCAG 4.1.3 (AA) | ARIA snapshot, screen reader |
spinner/announce-outcome | Should | Usage | When the wait ends, the page says how it went, in the same status message, or in an alert for an error that needs attention straight away | Open Components, beyond WCAG 4.1.3 (AA) | Screen reader |
spinner/keep-focus | Must | Usage | Showing a spinner never takes focus away, so it never replaces the element that has focus | Open Components | Keyboard |
spinner/remove-when-done | Must | Usage | It's taken out as soon as the wait ends or fails, so it never turns with nothing to wait for | Open Components, beyond WCAG 2.2.2 (A) | Review |
spinner/unknown-waits | Should | Usage | It's for waits of a few seconds and of unknown length, while a progress bar shows longer or measurable ones | Open Components | Review |
spinner/one-per-wait | Should | Usage | There's one spinner for each thing that's loading, shown where it's loading | Open Components | Review |
FAQ
Spinner, skeleton or progress bar?
All three show that something is on its way, and each one suits a different wait:
| When you're waiting for… | Use | Because |
|---|---|---|
| An action people just started, like saving or sending | A loading Button | It shows the wait where people acted, and keeps their focus |
| Something of unknown length, in one part of the page | A Spinner, next to a status message | It shows that something's happening, right where it's happening |
| Content whose layout you know, like a list or a card | A skeleton | It holds the content's place, so nothing moves when it arrives |
| Something you can measure, or that takes over 10 seconds | A progress bar | It shows how much is done, and how much is left |
Skeletons need the same care as the Spinner. They're hidden from assistive technologies, a status message says what's loading, and their shimmer stops when someone prefers reduced motion. A progress bar, on the other hand, has a role and a value of its own, so give it a name, like a <label> for a <progress>.
progressbar without a value is valid ARIA for a wait of unknown length, and some libraries give their spinners that role. It needs a name of its own, though, and screen readers only read it when people come across it, rather than when loading starts. Our Spinner leaves both to whatever shows it, which knows what's loading, and when it's done.aria-busy="true" tells assistive technologies that a region is in the middle of an update, so they can wait for it to finish before reading out its changes. It's useful on a live region that you update in several steps, set back to false once you're done, as Primer recommends. It doesn't announce anything on its own, though, and screen readers don't all treat it the same way, so it doesn't replace the status message.