---
title: "CSS Loading Spinners: Light, Smooth and Accessible"
description: "Build CSS loading spinners from one element, a conic gradient or three dots. Size them in em, delay them 300ms and make them accessible in buttons."
url: https://frontendhelpers.com/css-loading-spinner
updated: 2026-10-12
---

# 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](https://frontendhelpers.com/about) · 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](https://frontendhelpers.com/css-skeleton-loader) 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](https://frontendhelpers.com/css-loading-animations#anim-spinner-ring) 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.

```css
.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](https://frontendhelpers.com/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.

```css
.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.

```css
.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.

| Technique | Elements needed | What animates | Cost | Colour control |
| --- | --- | --- | --- | --- |
| Border ring | One | `transform: rotate()` on the element | Painted once, then rotated by the compositor; cheapest | `currentColor` arc, `color-mix()` track; flat colours only |
| Conic gradient + mask | One | `transform: rotate()` on the element | As the border ring, plus a slightly larger first paint | Any gradient: a fading tail or brand colours |
| Three dots | A wrapper and three children | `transform: scale()` and `opacity` on each dot, staggered | Three compositor animations; no repaint per frame | `currentColor` 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.

```css
.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](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-disabled) 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”.

```html
<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>
```

```css
.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; }
```

```js
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](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role) 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.

```html
<!-- 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](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion) 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](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html) 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.

```css
@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.

## Keep going

- [CSS loading animations](https://frontendhelpers.com/css-loading-animations): Rings, dual rings, dots, bars and skeletons, each with markup and CSS to copy.
 
- [Skeleton loading screens](https://frontendhelpers.com/css-skeleton-loader): The better choice when you already know the shape of the content that is loading.
 
- [CSS animation performance](https://frontendhelpers.com/css-animation-performance): Why transform and opacity stay smooth while other properties repaint every frame.
