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.
Step-by-step resolution
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.
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-delayon 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
hiddenwhen 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.
Related
- Animating SVG with CSS — the parent topic
- Main-Thread Scheduling & Long Animation Frames — why the loader competes with the work
- Skeleton Loaders and Motion Accessibility — the other loading pattern