CSS Loading Spinner Generator

Four loading indicators in CSS, each with the reduced-motion rule and the role=status markup that decide whether anybody else knows the page is working.

Enable JavaScript to change the loader; the CSS below is for the defaults.

Kind

Around 800 to 1200ms reads as working. Much faster looks frantic; much slower looks stuck.

The part that does not move. It needs to be visible against the page or the spinner reads as a fragment.

Also used for the keyframes name, so two loaders on one page do not share an animation.

The preview follows prefers-reduced-motion, like the CSS it generates: with that setting on, the indicator stays and the movement stops.

loader.css
.spinner {
	width: 40px;
	height: 40px;
	border: 4px solid rgb(227 227 232);
	border-top-color: rgb(30 86 236);
	border-radius: 50%;
	animation: spinner-turn 900ms infinite linear;
}

@keyframes spinner-turn {
	to { transform: rotate(360deg); }
}

/* A spinning element is what prefers-reduced-motion exists to stop. Keep
   the indicator and drop the spin, rather than removing both. */
@media (prefers-reduced-motion: reduce) {
	.spinner {
		animation: none;
		border-color: rgb(227 227 232);
		border-top-color: rgb(30 86 236);
	}
}

/* The markup matters as much as the CSS: without role="status" and a
   label, a screen reader user waits in silence. Say what is loading.

   <div class="spinner" role="status" aria-label="Loading results"></div> */

The animation in a spinner is four lines. Everything that makes it a good loading indicator is the part usually left out, so this generates all of it.

It needs to be announced. A <div> that spins tells a screen reader user nothing, and they wait in silence wondering whether the click registered. The markup in the output carries role="status" and a label, and the label says what is loading rather than just “Loading”.

It needs to respect prefers-reduced-motion. A spinning element is precisely what that setting exists to stop. The honest response is not to remove the indicator but to stop the movement and keep the sign that something is happening, which is what the generated media query does.

And it should animate transform rather than anything that touches layout, so the indicator keeps moving while the main thread is busy doing the work it is waiting for.

How to use

  1. Pick a kind: a ring, a dual ring, three dots, or an indeterminate bar.
  2. Set size, thickness and duration. Between 800 and 1200ms reads as working.
  3. Copy the CSS, then copy the markup out of the comment at the bottom and give the label a real description.

Example

.spinner {
	width: 40px;
	height: 40px;
	border: 4px solid rgb(227 227 232);
	border-top-color: rgb(30 86 236);
	border-radius: 50%;
	animation: spinner-turn 900ms infinite linear;
}

@keyframes spinner-turn {
	to { transform: rotate(360deg); }
}

/* A spinning element is what prefers-reduced-motion exists to stop. Keep
   the indicator and drop the spin, rather than removing both. */
@media (prefers-reduced-motion: reduce) {
	.spinner {
		animation: none;
		border-color: rgb(227 227 232);
		border-top-color: rgb(30 86 236);
	}
}

/* The markup matters as much as the CSS: without role="status" and a
   label, a screen reader user waits in silence. Say what is loading.

   <div class="spinner" role="status" aria-label="Loading results"></div> */

The keyframes name comes from the selector, so two loaders on one page do not quietly share one animation and fight over its duration.

Pitfalls

A spinner with no role="status" is invisible to assistive technology. Give it the role and a label, and say what is loading: “Loading your orders” rather than “Loading”.

Do not remove the indicator under reduced motion. Stop the spin, keep the sign. Removing both leaves somebody with no idea whether anything is happening.

Animate transform, not layout. Animating width, margin or top forces layout on every frame, which is exactly what a loading state cannot afford.

Under about 500ms, show nothing. A spinner that flashes for 200ms reads as a glitch. Delay showing it, and once shown, keep it for a minimum time so it does not blink out.

The track has to be visible. A ring whose track matches the page background reads as a moving fragment rather than a ring, which looks broken rather than busy.

A determinate bar is better when you know the progress. These are all indeterminate. If you can report a real percentage, use <progress> and say so.

Do not spin something meaningful. A rotating logo is a rotating logo. Use a plain shape, so the spin is read as a state rather than as branding.

Compatibility

@keyframes, animation, border-radius and the transforms used here work in every browser in use.

prefers-reduced-motion is supported in every current browser and ignored by very old ones, which is a safe failure: they simply keep the animation.

role="status" maps to an ARIA live region with a polite announcement, which is what a loading indicator wants. It is announced when it appears, so mount the element when loading starts rather than keeping it in the page hidden.

The stylesheet is built by one function, mirrored in PHP for the first paint, with a case file comparing the two on every build across all four kinds and an unknown one. The tests also assert that no keyframe animates a layout property, which is the kind of thing that creeps in during a later edit.

The preview holds itself to the same rules it generates, including the reduced-motion block, so with that setting on the indicator stops moving in the preview as well.

Frequently asked questions

When should a spinner appear?
After a delay, typically 300 to 500ms, so quick responses never show one. Once shown, keep it visible long enough to be read, around 500ms, or it flickers.
Spinner or skeleton?
A skeleton is better when you know the shape of what is coming and it takes a while. A spinner is better for a short wait or an action with no layout to preview.
How do I announce progress properly?
role="status" announces when the element appears. For a long operation, update the text inside it, for example “Loading page 2 of 5”, and the live region announces the change.
Why is the animation name based on the selector?
So two loaders on one page keep their own keyframes. A shared @keyframes spin is fine until somebody changes its duration for one of them.
Should I use a GIF instead?
No. It is a request, it cannot change colour, it does not respect reduced motion, and it looks wrong on a different background. CSS has no downside here.
Weekly drops

New tools, when there are new tools

One email when something worth using ships. No schedule to fill, so no filler.

Your address goes nowhere else, and one click unsubscribes.