CSS loading spinners: light, smooth and accessible

Three spinner techniques that cost the browser almost nothing, scale with the text around them, take their colour from it, and tell screen readers what is going on.

CSS recipe · By AurumCodeWorks · 7 min read · Updated 12 October 2026

Border ring — one class at three font sizes; size and colour come from the surrounding text.
Conic tail — a masked conic-gradient(), faded in after a 300ms delay.

Busy button — press it: staggered dots, a stable width, and the status announced separately.

What a spinner promises, and when a skeleton is better

A spinner makes exactly one claim: something is happening, so please wait. It says nothing about what is coming or how long it will take. That suits actions whose result has no predictable shape, such as saving a form or sending a message, and small waits inside a control such as a button.

It is the wrong tool for a section whose layout you already know, such as a card grid or a feed. There a skeleton screen does more: it shows where content will land and reserves the space, so nothing jumps. For jobs that regularly take more than a few seconds, use a determinate <progress> bar and offer a way to cancel.

The plain ring below also lives in our loading animations library as a fixed-size snippet. This recipe covers the decisions behind it and the parts a snippet cannot show: sizing, timing, buttons and screen readers.

The one-element ring

The familiar spinner is one element with a round border, one side of which has a different colour. Rotate the element and that arc chases itself round the circle.

.sp-ring {
  display: inline-block;
  box-sizing: border-box;
  width: 1em;
  height: 1em;
  border: 0.14em solid color-mix(in srgb, currentColor 22%, transparent);
  border-top-color: currentColor;
  border-radius: 50%;
  vertical-align: -0.125em;
  animation: sp-spin 0.8s linear infinite;
}
@keyframes sp-spin {
  to { transform: rotate(1turn); }
}

The faint track is not a second hard-coded colour: color-mix() takes 22% of the current text colour, so track and arc always match. The border width is in em, so a bigger spinner gets a thicker ring rather than a hairline, and vertical-align lines it up with adjacent text.

The animation changes only transform, which is why it is cheap. The browser paints the ring once, then the compositor rotates that layer every frame without style, layout or paint work on the main thread. That matters for a spinner more than most animations, because it is on screen exactly when the main thread is busiest: parsing a response, building new DOM. A spinner that animated border-top-color, or a gradient angle through a registered custom property, would repaint every frame and stall at those moments. Our guide to CSS animation performance explains the pipeline behind this.

A conic-gradient ring with a fading tail

A border cannot fade from solid to nothing, because each side is a single colour. conic-gradient() can: it sweeps colour clockwise from the top, so transparent to currentColor draws a comet whose head sits at twelve o’clock. A radial-gradient mask then cuts out the middle of the disc.

.sp-conic {
  display: inline-block;
  width: 1em;
  height: 1em;
  border-radius: 50%;
  background: conic-gradient(transparent 10%, currentColor);
  mask: radial-gradient(farthest-side, transparent calc(100% - 0.15em), #000 calc(100% - 0.15em + 1px));
  animation: sp-spin 1s linear infinite;
}

The mask works on alpha: where it is opaque the element shows, where it is transparent the element disappears, and the colour you write is irrelevant. This one is transparent until 0.15em from the edge, leaving a ring of that thickness at any size; the extra pixel between the two stops softens the edge. Neither feature needs a prefix in 2026: MDN lists conic-gradient() as available across browsers since November 2020 and the unprefixed mask since December 2023.

The motion is still a plain rotation of something painted once, so the running cost matches the border ring. You pay a slightly larger first paint and get a tail, several colour stops or a brand gradient in return.

Three dots with staggered delays

Three small elements grow and brighten one after another. Every dot runs the same keyframes; the wave comes entirely from animation-delay, which starts the second and third dots slightly later and keeps that offset on every cycle.

.sp-dots {
  display: inline-flex;
  gap: 0.25em;
}
.sp-dots span {
  width: 0.45em;
  height: 0.45em;
  border-radius: 50%;
  background: currentColor;
  animation: sp-dot 1.2s ease-in-out infinite both;
}
.sp-dots span:nth-child(2) { animation-delay: 0.16s; }
.sp-dots span:nth-child(3) { animation-delay: 0.32s; }
@keyframes sp-dot {
  0%, 80%, 100% { transform: scale(0.5); opacity: 0.4; }
  40% { transform: scale(1); opacity: 1; }
}

Dots sit naturally inside a button, where a ring can look heavy. They cost three elements and three animations, but each animates only transform and opacity, so nothing repaints. Keep the stagger short: here the wave spans 0.32 seconds of a 1.2-second loop, so the dots rest together for part of each cycle, which reads as rhythm rather than blur.

TechniqueElements neededWhat animatesCostColour control
Border ringOnetransform: rotate() on the elementPainted once, then rotated by the compositor; cheapestcurrentColor arc, color-mix() track; flat colours only
Conic gradient + maskOnetransform: rotate() on the elementAs the border ring, plus a slightly larger first paintAny gradient: a fading tail or brand colours
Three dotsA wrapper and three childrentransform: scale() and opacity on each dot, staggeredThree compositor animations; no repaint per framecurrentColor by default; each dot can take its own colour

Size with em, colour with currentColor

A 44px ring looks right in an empty panel and absurd inside a 14px button. Sizing every dimension in em (width, height, border, dot size, gap) ties the spinner to the font size of its container, so it grows in a heading and shrinks in a small button. To enlarge a standalone spinner, set font-size on it rather than overriding width and height.

currentColor is the element’s computed text colour, so a spinner in a primary button matches its label, one in an error message turns red, and a theme switch updates every spinner. Set color only when the spinner should differ from its surroundings. The first demo is one class at three font sizes and colours, nothing else changed.

Wait about 300ms before showing anything

Many requests finish within a few hundred milliseconds. A spinner shown the instant one starts then exists for a frame or two, which looks like a glitch and makes a fast interface feel slow. Reveal it only once the wait is long enough to notice; 300ms is a reasonable default to tune against your real response times.

CSS can do this alone. Because animation-fill-mode is both, the wrapper’s first keyframe applies during the delay, so it stays transparent for 300ms and then fades in. Remove the spinner before then and nobody sees it. The second demo uses this wrapper.

.sp-delayed {
  animation: sp-appear 0.2s 0.3s both;
}
@keyframes sp-appear {
  from { opacity: 0; }
}

The delay sits on a wrapper so the fade and the rotation stay independent. In JavaScript, a setTimeout cleared when the response lands does the same job. Once a spinner has appeared, keep it for at least 400ms or so, so a response landing at 320ms does not cause a flicker of its own.

A spinner inside a button

The user needs to know their press registered, and you need to block a second press while the request is in flight.

Keep the width stable. Swapping the label for a spinner shrinks the button and shifts its neighbours. In the third demo the label and dots share one grid cell; while busy, the label drops to opacity: 0, so it still sets the width and still names the button. visibility: hidden or display: none would remove that text from the accessibility tree.

Block repeat presses without losing focus. The disabled attribute takes the button out of the focus order, and a focused button that becomes disabled loses focus, which can leave keyboard and screen reader users stranded. As MDN’s aria-disabled reference explains, aria-disabled="true" exposes the state but leaves the element focusable, so your script must ignore activation; the demo’s handler returns early.

Announce the outcome elsewhere. aria-busy marks an element as being updated and is mainly useful on live regions and widgets; on a plain button, screen reader support is patchy. The demo sets it as honest state and a styling hook, but the words come from a visually hidden status element that reads “Saving…” and then “Changes saved”.

<button type="button" class="sp-btn" id="sp-save">
  <span class="sp-btn-label">Save changes</span>
  <span class="sp-dots" aria-hidden="true"><span></span><span></span><span></span></span>
</button>
<p id="sp-save-status" class="visually-hidden" role="status"></p>
.sp-btn {
  display: inline-grid;
  place-items: center;
  padding: 0.75em 1.5em;
  border: 0;
  border-radius: 8px;
  background: #06b6d4;
  color: #04111a;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.sp-btn > * { grid-area: 1 / 1; }
.sp-btn .sp-dots { display: none; }
.sp-btn[aria-busy="true"] .sp-btn-label { opacity: 0; }
.sp-btn[aria-busy="true"] .sp-dots { display: inline-flex; }
.sp-btn[aria-disabled="true"] { cursor: progress; }
.sp-btn:focus-visible { outline: 2px solid #f8fafc; outline-offset: 3px; }
const spBtn = document.getElementById('sp-save');
const spStatus = document.getElementById('sp-save-status');
spBtn.addEventListener('click', () => {
  if (spBtn.getAttribute('aria-disabled') === 'true') return;
  spBtn.setAttribute('aria-disabled', 'true');
  spBtn.setAttribute('aria-busy', 'true');
  spStatus.textContent = 'Saving…';
  setTimeout(() => {
    spBtn.removeAttribute('aria-busy');
    spBtn.removeAttribute('aria-disabled');
    spStatus.textContent = 'Changes saved';
  }, 2500);
});

In real code, replace the timer with your request, and on failure restore the button and put the error in the status text.

Make it make sense to screen readers

A turning shape means nothing to a screen reader, so hide it with aria-hidden="true" and say the same thing in words inside a role="status" element. MDN’s status role reference describes it as an advisory live region with implicit aria-live="polite" and aria-atomic="true": when its text changes, the whole message is read without interrupting the user, and it should never take focus.

<!-- In the page from the start, and empty -->
<p id="results-status" class="visually-hidden" role="status"></p>

<!-- The visible spinner, hidden from assistive technology -->
<div class="sp-delayed">
  <span class="sp-ring" aria-hidden="true"></span>
</div>

Live regions announce changes, not text already present. So keep the status element in the page from the start, empty, and set its text (“Loading results…”) when the wait begins; a freshly inserted region that already contains words is announced by some screen reader and browser pairings and skipped by others. When loading ends, replace the text with the outcome, such as “24 results”.

Our library snippet takes the shorter route: role="status" and aria-label="Loading" on the spinner itself. That names the element for someone who reaches it while browsing, but an empty labelled region has no content to change, so it is often not announced when it appears. When the announcement matters, use visually hidden text.

Reduced motion: slow it down, don’t switch it off

prefers-reduced-motion exposes the operating-system setting (Reduce motion on macOS and iOS, Animation effects on Windows 11, Remove animations on Android). MDN’s prefers-reduced-motion reference frames it as a request to remove, reduce or replace motion, and its own example swaps a scaling animation for a slow opacity fade.

For a spinner, animation: none is the wrong answer. A frozen ring looks like a stalled request, and change over time is the only way a spinner shows that work continues. The W3C’s Understanding document for Pause, Stop, Hide agrees that a loading animation can count as essential when users cannot interact meanwhile and showing no progress could make them think the content had frozen.

So reduce rather than remove. A small ring turning in place is contained motion, not the large panning and zooming linked to vestibular discomfort; slowing it to 2.4 seconds per turn calms it while it still visibly moves. The dots swap their scaling wave for an opacity-only pulse.

@keyframes sp-fade {
  0%, 80%, 100% { opacity: 0.35; }
  40% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .sp-ring,
  .sp-conic { animation-duration: 2.4s; }
  .sp-dots span { animation-name: sp-fade; }
}

Frequently asked questions

How do I make a CSS loading spinner accessible?

Hide the spinning element with aria-hidden="true" and say the same thing in words in a visually hidden role="status" element that is already in the page. Set its text to Loading… when the wait starts and replace it with the result when it ends. Live regions announce changes, so an empty element with only an aria-label is often not announced.

Should a loading spinner stop when prefers-reduced-motion is set?

Not entirely. A frozen spinner looks like a stalled page, which defeats its purpose. Slow the rotation down, or replace movement with a gentle opacity pulse, so the indicator still shows that work is happening without the motion that can cause discomfort.

How do I stop a spinner flashing on fast responses?

Delay it. Keep the spinner transparent for about 300ms with animation-delay and animation-fill-mode: both, then fade it in, or add it from a setTimeout that you clear when the response arrives. A response that lands within the delay never shows a spinner at all.

Why does my button change width when it shows a spinner?

The label is being replaced or removed, so the button shrinks to fit the spinner. Stack the label and the spinner in the same grid cell and hide the label with opacity: 0. The label then keeps the button's width and its accessible name while the spinner shows on top.