SVG Spinners and Loaders That Stay Smooth

Part of Animating SVG with CSS in Core CSS Animation Fundamentals.

The problem

A loading spinner appears when the user submits a form. For the first few hundred milliseconds it turns smoothly; then the response arrives, the app parses it and renders a large result set, and the spinner freezes mid-rotation until the work is done. The one moment the indicator exists to reassure the user is the moment it looks broken.

Popular spinner designs make it worse. The “Material” arc that grows and shrinks while rotating animates stroke-dasharray and stroke-dashoffset, both paint properties that only update when the main thread is free.

Root cause analysis: the loader competes with the work it reports

A loading indicator is on screen precisely while the main thread is busy — parsing responses, rendering components, hydrating. Anything the indicator needs from the main thread per frame will not happen during those tasks.

Compositor-driven animations keep running. An infinite rotate animation on the outer svg element, once started, is sampled on the compositor and continues through long tasks. So does an opacity animation. They cannot start or change while a task runs, but a spinner that is already spinning keeps spinning.

Paint-driven animations freeze. Animating stroke-dashoffset, stroke-dasharray, d, r or fill on shapes inside the SVG requires repainting the SVG each frame, which requires the main thread. Transform animations on shapes inside an SVG are also less reliably composited than on the outer element, as covered in the parent topic.

Starting matters. A spinner inserted into the DOM in the same task that then does the heavy work never renders its first frame until that task ends. Insert or reveal the indicator, yield, and then start the work — the ordering described in yielding to the main thread during animations.

Two spinners during a 400 ms render taskThe rotating outer svg keeps turning; the dash animation freezes until the task ends.Two spinners during a 400 ms render taskMain threadidlerender taskidle600 msRotate outer svgturning600 msAnimated stroke-dashoffsetmovingfrozenmoving600 msidlerender taskturningmovingfrozen
The rotating outer svg keeps turning; the dash animation freezes until the task ends.

Step-by-step resolution

Rebuilding a spinner for busy momentsStatic geometry, compositor motion, separate announcement.Rebuilding a spinner for busy moments1Draw a fixed arc with a static stroke-dasharray, using pathLength="1".Geometry painted once2Apply an infinite rotate animation to the outer svg element.Keeps turning during long tasks3For a dots loader, pulse each dot's opacity with staggered delays.Compositor-friendly secondary motion4Place a visually hidden status message next to it, or set aria-busy on the region.Assistive technology knows loading is happening5Show the indicator only after 300 to 500 ms of waiting.No flash on fast responses6Reveal the spinner and yield before starting heavy work.
Static geometry, compositor motion, separate announcement.

Production code pattern

<div class="loader" role="status" hidden>
  <svg class="loader__ring" viewBox="0 0 50 50" aria-hidden="true">
    <circle cx="25" cy="25" r="20" pathLength="1" />
  </svg>
  <span class="visually-hidden">Loading results</span>
</div>
.loader__ring {
  inline-size: 2.5rem;
  block-size: 2.5rem;
  animation: ring-spin 900ms linear infinite;   /* outer svg: compositor-driven */
}
.loader__ring circle {
  fill: none;
  stroke: currentColor;
  stroke-width: 4;
  stroke-linecap: round;
  stroke-dasharray: 0.72 0.28;                  /* static arc, never animated */
}
@keyframes ring-spin { to { rotate: 1turn; } }

/* Delay the loader so fast responses never show it. */
.loader:not([hidden]) {
  animation: loader-appear 150ms ease-out 400ms both;
}
@keyframes loader-appear { from { opacity: 0; } }

@media (prefers-reduced-motion: reduce) {
  .loader__ring { animation: ring-pulse 1.6s ease-in-out infinite; }
  @keyframes ring-pulse { 50% { opacity: 0.45; } }
}
async function search(query) {
  const loader = document.querySelector('.loader');
  loader.hidden = false;                        // request the indicator's first frame
  results.setAttribute('aria-busy', 'true');
  await globalThis.scheduler?.yield?.();                 // let it render and start spinning
  try {
    const data = await fetchResults(query);
    await renderInChunks(data);                 // heavy work after the spinner is running
  } finally {
    loader.hidden = true;
    results.setAttribute('aria-busy', 'false');
  }
}

Rendering Impact: composite. The ring’s arc is painted once; the rotation and the appearance fade are compositor animations that continue while the render task runs. The reduced-motion pulse is also compositor-driven.

The appearance delay is part of the smoothness story. A spinner that flashes for 80ms on fast responses reads as flicker, and each flash costs a layer promotion and paint for nothing. With animation-delay: 400ms and both fill, the loader stays invisible for fast operations and fades in only when waiting is real.

The classic Material arc against a static rotating arcBoth communicate loading; one survives busy frames.The classic Material arc against a static rotating arcGrowing and shrinking arcAnimates stroke-dasharray and dashoffsetRepaints the svg every frameFreezes during long tasksStalls when needed mostStatic arc, rotating svgArc drawn once with pathLengthOnly rotate animatesKeeps turning through long tasksSmooth under load
Both communicate loading; one survives busy frames.

Determinate progress rings

When progress is known, a ring that fills to a percentage is more informative than a spinner. Filling the ring is a stroke-dashoffset change, which repaints — but it only needs to change when progress changes, not every frame. Update it at most a few times per second from the progress callback, with a short transition, and it costs a handful of small paints over the whole operation. Expose the value with role="progressbar" and aria-valuenow, not through the graphic.

Verification checklist

Constraints and trade-offs

  • A rotation cannot start during a long task; the reveal-and-yield ordering is required.
  • Very small spinners on low-density screens can alias as they rotate; use round caps and even sizes.
  • animation-delay on the loader means it can appear just as loading ends; hide it immediately on completion.
  • Determinate progress repaints on each update; throttle updates rather than animating per frame.
  • An infinite animation keeps a layer promoted; remove the loader from the DOM or set hidden when done.

Frequently asked questions

Why does my loading spinner freeze?

It depends on the main thread, either because it animates paint properties such as stroke-dasharray or because it started in the same task as the heavy work. Rotate a static graphic on the outer svg and yield before the work.

Is a CSS spinner better than an SVG spinner?

The rendering rule is the same for both: rotate or fade a pre-painted element. SVG makes the arc shape easy; a bordered div with one coloured side works equally well.

Should a spinner be announced to screen readers?

The loading state should be. Put a visually hidden status message near the indicator or set aria-busy on the updating region, and hide the graphic itself from assistive technology.

How long should I wait before showing a spinner?

Around 300 to 500ms. Anything faster usually completes before the user perceives waiting, and a flashing indicator is more distracting than none.