Spinner

User Experience (UX)

How a spinner behaves for every person and setting, so it's accessible, predictable, clear in every state and adaptive.

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, its loading state 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 secondNothing. A spinner that flashes on and off distracts more than it helps
A few seconds, of unknown lengthA Spinner, where the thing that's loading is
Longer than 10 seconds, or of a length you can measureA 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

RuleLevelScopeRequirementBasisCheck
spinner/decorativeMustComponentIt's hidden from assistive technologies with aria-hidden, and has no role or name of its ownWCAG 1.1.1 (A)Unit test
spinner/say-what-loadsMustUsageWhatever 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 loadingLabelWCAG 4.1.3 (AA)ARIA snapshot, screen reader
spinner/announce-outcomeShouldUsageWhen 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 awayOpen Components, beyond WCAG 4.1.3 (AA)Screen reader
spinner/keep-focusMustUsageShowing a spinner never takes focus away, so it never replaces the element that has focusOpen ComponentsKeyboard
spinner/remove-when-doneMustUsageIt's taken out as soon as the wait ends or fails, so it never turns with nothing to wait forOpen Components, beyond WCAG 2.2.2 (A)Review
spinner/unknown-waitsShouldUsageIt's for waits of a few seconds and of unknown length, while a progress bar shows longer or measurable onesOpen ComponentsReview
spinner/one-per-waitShouldUsageThere's one spinner for each thing that's loading, shown where it's loadingOpen ComponentsReview

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…UseBecause
An action people just started, like saving or sendingA loading ButtonIt shows the wait where people acted, and keeps their focus
Something of unknown length, in one part of the pageA Spinner, next to a status messageIt shows that something's happening, right where it's happening
Content whose layout you know, like a list or a cardA skeletonIt holds the content's place, so nothing moves when it arrives
Something you can measure, or that takes over 10 secondsA progress barIt 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>.

Copyright © 2026