Spinner
Spinners tell people that something is busy, whether that's saving a form, loading search results or checking for updates, when there's no telling how long it'll take. They're one of the simplest components in any interface, and still easy to get wrong. Think of a spinner that screen readers read out as "image", one that keeps turning for people who asked for less motion, or one that pushes the page down as it appears.
This page walks you through building one the right way. We'll look at:
- User Interface (UI) - how a spinner looks
- User Experience (UX) - how it behaves for every person and every setting
- Developer Experience (DX) - how developers work with it
- Agentic Experience (AX) - how agents can read and verify it
Every example on this page runs on our reference implementation, a Vue 3 component that does its part of every rule in the checklist, and the same one the Button shows while it's loading. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Spinner with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the DOM contract.
At a glance
| Aspect | Spinner |
|---|---|
| Element | <svg> |
| Role | None, since it's hidden from assistive technologies with aria-hidden |
| Name | None. Whatever shows it says what's loading, like the Button with its loadingLabel |
| Keyboard | None, since it can't be focused |
| States | None of its own. It turns while it's in the page, and pulses when someone prefers reduced motion |
| Size | 1em, the size of the text around it, unless --spinner--size sets another |
| Colour | currentColor, the colour of the text around it |
| Used by | Button |
| WCAG 2.2 | 1.1.1, 1.4.4, 1.4.11, 2.2.2, 2.3.3 and 4.1.3 |
User Interface (UI)
Let's start with how a spinner looks: its parts, its size and colour, how it moves and the one variable you can set.
Anatomy
- Container — The
<svg>, a square that turns as a whole, without taking up any more room. - Track — A faint full circle, which shows the path the indicator follows.
- Indicator — A quarter of the circle at full strength, which turns around the track.
Every spinner has all three parts, and nothing else. The indicator is what people watch, so it's the part that has to stand out (see Contrast), while the track stays faint. We recommend drawing both as strokes in one <svg>, with a 16 × 16 viewBox and a track at 25% opacity. The parts always render in the same order, which you can see in the DOM contract.
Size and colour
The Spinner takes its size and colour from where it sits, just like an icon. It's 1em wide, the size of the text around it, and it's drawn in currentColor, the colour of that text. Next to a label, it lines up without any setup, and to give it another size, you can set --spinner--size from a class.
Saving draft
Loading results
import styles from "./Loading.module.css";
export function Loading() {
return (
<>
<p>
<Spinner /> Saving draft
</p>
<h2>
<Spinner /> Loading results
</h2>
<Spinner className={styles.large} />
</>
);
}
.large {
--spinner--size: 2rem;
}
Inside a component, the component sets the size. In a Button, the Spinner is as big as the Button's icons, with --button--icon--size. A spinner on its own, in the middle of a panel that's loading, is where a larger size comes in.
Keep in mind that these examples only show sizes. On a real page, the text that says what's loading goes in a status message, so screen readers hear it too, as under Say what's loading.
States
The Spinner can't be hovered, focused or pressed, so it has no interactive states. It's in the page while something's busy and gone once it's done, and while it's there, it moves in one of two ways:
| State | Selector | Recommended look |
|---|---|---|
| Turning | .spinner | One full turn every 0.8 seconds, at a steady speed |
| Reduced motion | @media (prefers-reduced-motion: reduce) | No turning. The whole spinner fades to 40% and back every 1.6 seconds |
You'll find why it pulses under Motion. The wait it shows has states of its own, like done and failed, which belong to the page, and you'll find those under Every state.
Tokens
The Spinner doesn't read any theme tokens. Its colour is currentColor, so it follows whatever colour, theme or colour scheme the text around it follows. It has a single variable of its own:
| Variable | Default | Set by |
|---|---|---|
--spinner--size | 1em | Components that place it, like the Button with --button--icon--size, and your classes |
The Spinner's styles sit in the components cascade layer, so a class of yours that sets --spinner--size wins without any specificity tricks, as in the example above. To change its colour, set color on it, or on the text around it.
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. Each one is up to the page, and each one is designed.
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.
Developer Experience (DX)
Next, let's look at how developers use the Spinner. It has no props, events or slots. You render it while something's loading, next to the text that says what, and it takes everything else from where it sits.
import { Spinner } from "@/components/Spinner";
export function DraftStatus({ saving }: { saving: boolean }) {
return (
<p role="status">
{saving && (
<>
<Spinner /> Saving draft
</>
)}
</p>
);
}
Consistent
The Spinner follows the same conventions as every other component. Its styles sit in the components cascade layer, its variable is a token path, and its parts are marked with data-slot.
What it leaves out is just as deliberate. There's no size prop, since the size of the text around it is almost always the right one, and the components that use it set their own. There's no color prop either, since a spinner has no intent of its own, so it can't be a success or an error. And there's no label, since whatever shows the spinner names the wait. If your design system needs a size prop anyway, give it the same values as every other component, from xs to xl, rather than a scale of its own.
Type-safe
With no props, events or slots, there's nothing of its own to type: <Spinner /> is the whole API. Classes and attributes pass straight through to the <svg>. If you wrap it, type the wrapper's attributes as an <svg>'s, with ComponentProps<"svg"> in React or SVGAttributes from vue in Vue.
Composable
The root element is the <svg> itself, so classes, styles and attributes you pass land directly on it. A component that shows a spinner does three things:
- It marks the Spinner as one of its parts, with
data-slot. - It places it, and sizes it with
--spinner--size, from a selector that's more specific than.spinner. Both sit in thecomponentslayer, so the more specific one wins, whichever stylesheet loads first. - It announces the wait itself, since the Spinner stays silent.
That's all the Button does in Button.vue, along with hiding its content while the spinner covers it:
<template>
<!-- … -->
<Spinner v-if="loading" data-slot="spinner" />
</template>
<style scoped>
@layer components {
/* … */
.button[data-loading] > :not([data-slot="spinner"]) {
opacity: 0;
}
.button > [data-slot="spinner"] {
--spinner--size: var(--button--icon--size);
position: absolute;
inset: 0;
margin: auto;
}
}
</style>
Every component that shows a spinner renders this one, rather than drawing its own. That way, you can change your spinner once and see it change everywhere, which is why the Button has no slot for it.
Controllable
The Spinner doesn't keep any state of its own. It turns for as long as it's in the page, so you show it by rendering it and hide it by taking it out, with v-if, {#if} or &&, rather than with a prop. Once it's gone, there's nothing left animating. Everything else is within reach from outside:
| What | How you control it |
|---|---|
| Shown | Render it while something's loading, and take it out once it's done |
| Size | --spinner--size, from a class |
| Colour | color, on the Spinner or on the text around it |
Agentic Experience (AX)
Finally, let's look at how agents read, build and check a spinner. It's the one component they can't see, which is exactly how it should be.
Semantic
The Spinner isn't in the accessibility tree, so agents that read the page through it, with Playwright or a browser MCP server, don't see it at all. What they see is the text that says what's loading, which is one more reason to always show some. This is how they see the example under Say what's loading, while it checks:
- button "Check for updates" [disabled]
- status: Checking for updates
In a test, wait for that text rather than for the spinner, as in await expect(page.getByRole("status")).toHaveText("You're up to date"). A spinner without any text around it leaves agents, like screen reader users, with nothing to wait for.
Described
Here's the whole contract in one block, for agents to read before they build or review a spinner:
component: Spinner
summary: Shows that something is busy, for a wait of unknown length. Visual only.
standard: https://opencomponents.dev/docs/components/spinner
element: svg # aria-hidden="true", with no role or name: whatever shows it says what's loading
props: {} # none: it takes its size and colour from the text around it
parts: [track, indicator] # data-slot, in this order
tokens:
theme: [] # none: it's drawn in currentColor
variables: [--spinner--size] # 1em by default
layer: components # in Tailwind's order: theme, base, components, utilities
rules: https://opencomponents.dev/docs/components/spinner#checklist
It's also published on its own at /raw/docs/components/spinner.yaml, with every rule from the checklist in place of the rules link. That's every requirement for a spinner in a fraction of this page's length, for agents that need the rules rather than the reasoning behind them.
Every rule has a stable ID, like spinner/say-what-loads, so you can cite it in reviews and commits, and so can your agents.
Deterministic
The Spinner always renders the same markup, leaving out Vue's comments and data-v-* scoping attributes:
<svg class="spinner" viewBox="0 0 16 16" aria-hidden="true">
<circle data-slot="track" cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="2" opacity="0.25" />
<path data-slot="indicator" d="M8 1.5a6.5 6.5 0 0 1 6.5 6.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
- Its parts are marked with
data-slotand always come in the same order:track, thenindicator. - There's nothing random or generated. It's drawn with plain strokes rather than a gradient, which would need an
id, so the markup is the same on the server, on the client and in every snapshot, however many spinners there are on the page. - Attributes you pass are added to the
<svg>, like the Button'sdata-slot="spinner".
Verifiable
Every rule in the checklist can be checked, and about a third of them automatically.
Unit tests
The reference implementation comes with a test for every rule that the checklist checks with a unit test. You'll find them in Spinner.test.ts, under Reference implementation. We run them on every change to this page, so the code you see always passes them.
In the browser
Most of the Spinner's rules are about the page around it, so they're best checked in the browser. You can hold a request back to look at the page while it waits, use an ARIA snapshot to check what agents and screen readers find, emulate reduced motion, and run axe over the lot:
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
test("checking for updates says what it's doing, and how it went", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
// Hold the check back until the test lets it go.
let finish!: () => void;
const finished = new Promise<void>((resolve) => (finish = resolve));
await page.route("**/api/updates", async (route) => {
await finished;
await route.fulfill({ json: { available: false } });
});
await page.goto("/settings");
await page.getByRole("button", { name: "Check for updates" }).click();
// Agents and screen readers find the text, never the spinner, which pulses instead of turning.
await expect(page.getByRole("status")).toMatchAriaSnapshot(`- status: Checking for updates`);
await expect(page.locator(".spinner")).toHaveCSS("animation-name", /pulse/);
const { violations } = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
.analyze();
expect(violations).toEqual([]);
// Once it's done, the spinner is gone and the outcome is in its place.
finish();
await expect(page.getByRole("status")).toHaveText("You're up to date");
await expect(page.locator(".spinner")).toHaveCount(0);
});
Some things still need a human. Try the page with a screen reader, and check that the spinner is never read out, while the page says what's loading and how it went. Then turn on forced colours, which you can emulate in Chrome DevTools under Rendering, and check that the spinner stays visible.
Prompts
You can point your agent straight at this page. To build a spinner, you could use:
Build a Spinner component for our React design system that meets the Open Components
Spinner standard: https://opencomponents.dev/raw/docs/components/spinner.md
Follow its DOM contract and its tokens. Port its tests and make them pass.
Then check your work against every rule in its checklist with a Component or Both
scope, and cite the rule ID for any rule you can't meet.
To review one, the contract is enough:
Review our Spinner against the Open Components Spinner contract:
https://opencomponents.dev/raw/docs/components/spinner.yaml
For each rule with a component or both scope, report pass or fail with the rule ID
and the evidence from our code. Then fix the failures.
And to review how a screen shows its loading states:
Review how our search page shows that it's loading against the Open Components
Spinner contract: https://opencomponents.dev/raw/docs/components/spinner.yaml
For each rule with a usage or both scope, report pass or fail with the rule ID and
the evidence from our code. Then fix the failures.
Checklist
Here's every rule in one place, along with where it comes from and how to check it. A spinner meets the standard when it meets every must, and each should is expected unless you have a good reason not to follow it.
The scope says who meets a rule. Component rules are met by the Spinner itself, so they're the ones to check when you build or review one. Usage rules are met by the screens and components that show it, like saying what's loading, so they're the ones to check when you review a screen. Both rules need the two to work together, like contrast: the Spinner takes the colour of the text around it, as long as that text passes.
The basis says where a rule comes from. A few rules rest on WCAG 2.2, which we follow at level AA, and name the success criteria behind them. The rest are Open Components rules, which are our own, like showing one spinner per wait. Where a rule goes further than WCAG asks at level AA, its basis says so: the reduced-motion rule comes from level AAA, and a rule that asks for more than its criterion says beyond, like announcing how a wait went, which WCAG only asks for when there's a message to announce.
UI rules
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
spinner/em-sized | Should | Component | It's 1em by default, so it matches the text around it, and --spinner--size sets any other size | Open Components | Review |
spinner/current-color | Must | Component | It's drawn in currentColor, so it follows the text around it, every colour scheme and forced colours mode | Open Components | Emulation |
spinner/contrast | Must | Both | The indicator has a contrast of 3:1 against what's behind it | WCAG 1.4.11 (AA) | Contrast checker |
spinner/reduced-motion | Must | Component | It doesn't turn when prefers-reduced-motion: reduce is set, but still shows that something's happening, like with a pulse | WCAG 2.3.3 (AAA), Open Components | Emulation |
spinner/stable-layout | Must | Both | It keeps a fixed size while it turns, and showing or hiding it doesn't move anything around it | Open Components | Visual regression test |
UX rules
| 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 |
DX rules
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
spinner/root-element | Must | Component | The root element is the <svg> itself, so classes and attributes like data-slot land on it | Open Components | Unit test |
spinner/stateless | Should | Component | It has no props or state of its own, and it shows for as long as it's rendered | Open Components | Review |
spinner/one-implementation | Should | Usage | Components that show a spinner render the Spinner, rather than drawing one of their own | Open Components | Review |
AX rules
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
spinner/deterministic-dom | Must | Component | It always renders the same markup, with nothing generated, like an id | Open Components | Unit test |
spinner/data-attributes | Should | Component | Its parts are marked with data-slot, in a fixed order | Open Components | Unit test |
Reference implementation
Our Vue 3 reference implementation does its part of every rule on this page, powers every example on it and is what the Button shows while it's loading. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves: hidden from assistive technologies, drawn in currentColor, 1em by default and pulsing with reduced motion.
Spinner.test.ts holds its tests. They run on Vitest (with environment: "jsdom" and globals: true), using @testing-library/vue and @testing-library/jest-dom.
<template>
<svg class="spinner" viewBox="0 0 16 16" aria-hidden="true">
<!-- Visual only: whatever shows the spinner tells screen readers what's loading. -->
<circle data-slot="track" cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="2" opacity="0.25" />
<path
data-slot="indicator"
d="M8 1.5a6.5 6.5 0 0 1 6.5 6.5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</template>
<style scoped>
/* The same layer order as Tailwind CSS. The spinner's styles go in `components`,
so a class of yours that sets its size wins without any specificity tricks. */
@layer theme, base, components, utilities;
@layer components {
.spinner {
/* The size of the text around it, like an icon. Components that place it set their own. */
--spinner--size: 1em;
/* Inline, like a letter, even under resets that make every svg a block, like Tailwind's Preflight. */
display: inline-block;
flex-shrink: 0;
width: var(--spinner--size);
height: var(--spinner--size);
/* Centred on the text around it, when it sits in a line of text. */
vertical-align: -0.125em;
animation: spinner-turn 0.8s linear infinite;
}
@keyframes spinner-turn {
to {
rotate: 1turn;
}
}
@keyframes spinner-pulse {
50% {
opacity: 0.4;
}
}
/* With reduced motion, the spinner pulses instead of turning. */
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: spinner-pulse 1.6s ease-in-out infinite;
}
}
}
</style>
import { render } from "@testing-library/vue";
import { describe, expect, it } from "vitest";
import Spinner from "./Spinner.vue";
describe("Spinner", () => {
it("is hidden from assistive technologies, with no role or name of its own", () => {
const { container } = render(Spinner);
const spinner = container.querySelector("svg");
expect(spinner).toHaveAttribute("aria-hidden", "true");
expect(spinner).not.toHaveAttribute("role");
expect(spinner?.querySelector("title")).toBeNull();
});
it("renders the svg itself, so classes and attributes land on it", () => {
const { container } = render(Spinner, { attrs: { class: "large", "data-slot": "spinner" } });
const spinner = container.firstElementChild;
expect(spinner?.tagName).toBe("svg");
expect(spinner).toHaveClass("spinner", "large");
expect(spinner).toHaveAttribute("data-slot", "spinner");
});
it("renders the same markup every time, with its parts in a fixed order", () => {
const first = render(Spinner).container.innerHTML;
const { container } = render(Spinner);
expect(container.innerHTML).toBe(first);
const parts = [...container.querySelector("svg")!.children].map((part) => part.getAttribute("data-slot"));
expect(parts).toEqual(["track", "indicator"]);
});
});
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.<svg> in Spinner.vue, and every component that shows a spinner follows, which is why the Button has no slot for its spinner: it's one decision for your whole app, rather than for each button. Keep what makes it a Spinner, though. It's hidden with aria-hidden, drawn in currentColor, 1em by default, pulses with reduced motion and marks its parts with data-slot. If you use an icon, like Lucide's LoaderCircle, leave out any class that spins it, like Tailwind's animate-spin, since the Spinner already does.Sources
- Web Content Accessibility Guidelines (WCAG) 2.2
- Understanding SC 2.2.2: Pause, Stop, Hide, for loading animations
- Understanding SC 2.3.3: Animation from Interactions
- Understanding SC 4.1.3: Status Messages
- WAI-ARIA 1.2: progressbar and aria-busy
- CSS Color Adjustment: forced colors mode
- Nielsen Norman Group: Progress Indicators Make a Slow System Less Insufferable
- Primer: Spinner, for leaving the announcement to visible text and the delay before it appears
- Primer: Loading, for spinners, skeletons and
aria-busy