Animated SVG loading spinners with pure CSS
SVG Weave team
Published
Five copy-paste SVG loading spinners in pure CSS keyframes, no JavaScript. Includes accessible markup (role=status, aria-label) and reduced-motion handling.
You can build a loading spinner with one inline SVG and a few lines of CSS @keyframes. No JavaScript, no image files, no library. This post gives you five complete spinners you can paste into a page: a rotating arc, bouncing dots, a pulsing ring, a stroke-draw loop and a Material-style growing arc. Then it covers the accessibility parts people usually skip: role="status", an accessible name, and prefers-reduced-motion.
Every spinner below is self-contained: markup plus CSS. Each one uses currentColor or a single accent color, so changing the look takes one edit.
Ground rules for all five
A few choices apply to every example, so they are worth stating once.
- Animate
transformandopacitywherever you can. They change how an element is drawn without forcing the browser to recompute layout. The performance checklist covers this in detail. - Set the pivot explicitly. For SVG shapes, the default
transform-boxisview-box, so a baretransform-origin: centerrefers to the whole SVG viewport, not the shape. Settingtransform-box: fill-boxmakescentermean the center of the shape itself. Seetransform-boxon MDN. - Wrap motion in
prefers-reduced-motion: no-preference. The static version is the default, and the animation is an enhancement. More on this at the end. - Use unique class names if you put more than one spinner on a page. The examples are prefixed (
.sp-arc,.sp-dotsand so on) so they do not collide.
1. Rotating arc
The simplest spinner: a faint full ring with a short bright arc on top, rotating at a constant speed. Constant rotation is one of the few cases where linear easing is correct.
The arc is a circle with stroke-dasharray set so only a quarter of the circumference is drawn. With radius 20 the circumference is about 125.7, so a dash of 31.4 followed by a gap of 125.7 draws a quarter arc.
<svg class="sp-arc" role="status" aria-label="Loading"
viewBox="0 0 50 50" width="48" height="48">
<circle cx="25" cy="25" r="20" fill="none"
stroke="currentColor" stroke-opacity="0.2" stroke-width="5"/>
<circle class="sp-arc__head" cx="25" cy="25" r="20" fill="none"
stroke="currentColor" stroke-width="5" stroke-linecap="round"
stroke-dasharray="31.4 125.7"/>
</svg>
.sp-arc { color: #6366f1; }
.sp-arc__head {
transform-box: fill-box;
transform-origin: center;
}
@media (prefers-reduced-motion: no-preference) {
.sp-arc__head {
animation: sp-arc-spin 0.9s linear infinite;
}
}
@keyframes sp-arc-spin {
to { transform: rotate(360deg); }
}
Change the color by setting color on the SVG. Change the size with width and height; the viewBox scales the drawing.
2. Bouncing dots
Three dots that scale up and fade in sequence. The stagger comes from animation-delay on the second and third circles. The keyframes hold the dot small for most of the cycle (0%, 80%, 100%) and swell it at 40%, which reads as a quick pulse.
<svg class="sp-dots" role="status" aria-label="Loading"
viewBox="0 0 60 20" width="72" height="24" fill="currentColor">
<circle cx="10" cy="10" r="6"/>
<circle cx="30" cy="10" r="6"/>
<circle cx="50" cy="10" r="6"/>
</svg>
.sp-dots { color: #0ea5e9; }
.sp-dots circle {
transform-box: fill-box;
transform-origin: center;
}
@media (prefers-reduced-motion: no-preference) {
.sp-dots circle {
animation: sp-dots-pulse 1.2s ease-in-out infinite both;
}
.sp-dots circle:nth-child(2) { animation-delay: 0.15s; }
.sp-dots circle:nth-child(3) { animation-delay: 0.3s; }
}
@keyframes sp-dots-pulse {
0%, 80%, 100% { transform: scale(0.4); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
animation-fill-mode: both (the both keyword in the shorthand) keeps the delayed dots in their starting state during the delay instead of flashing at full size.
3. Pulsing ring
Two rings expand from the center and fade out, offset by half a cycle so one is always starting as the other ends. This looks like a radar ping and works well for "waiting for a connection" states.
vector-effect="non-scaling-stroke" keeps the stroke width constant while the circle scales. Without it, the stroke would grow with the ring.
<svg class="sp-ring" role="status" aria-label="Loading"
viewBox="0 0 50 50" width="48" height="48"
fill="none" stroke="currentColor" stroke-width="2">
<circle cx="25" cy="25" r="22" vector-effect="non-scaling-stroke"/>
<circle cx="25" cy="25" r="22" vector-effect="non-scaling-stroke"/>
</svg>
.sp-ring { color: #10b981; }
.sp-ring circle {
transform-box: fill-box;
transform-origin: center;
opacity: 0.6;
}
@media (prefers-reduced-motion: no-preference) {
.sp-ring circle {
animation: sp-ring-ping 1.6s ease-out infinite both;
}
.sp-ring circle:nth-child(2) { animation-delay: 0.8s; }
}
@keyframes sp-ring-ping {
from { transform: scale(0.2); opacity: 0.9; }
to { transform: scale(1); opacity: 0; }
}
The static fallback is two overlapping rings at 60% opacity, which still reads as a ring.
4. Stroke-draw loop
This one draws a circle around itself, holds, then un-draws it. It relies on stroke-dasharray and stroke-dashoffset: set the dash and the gap to the full path length, then animate the offset so the dash slides along the path.
The pathLength="100" attribute tells the browser to treat the path as 100 units long no matter what its real length is. That removes the need to compute circumference by hand. The rotate(-90 25 25) attribute moves the start point from 3 o'clock to 12 o'clock. It is an SVG attribute, so it does not conflict with the CSS animation, which only changes the dash offset. Use butt caps here, because a round cap can draw a stray dot at the seam when the dash length hits zero.
<svg class="sp-draw" role="status" aria-label="Loading"
viewBox="0 0 50 50" width="48" height="48">
<circle cx="25" cy="25" r="20" fill="none"
stroke="currentColor" stroke-opacity="0.2" stroke-width="4"/>
<circle class="sp-draw__line" cx="25" cy="25" r="20" fill="none"
stroke="currentColor" stroke-width="4"
pathLength="100" stroke-dasharray="100"
transform="rotate(-90 25 25)"/>
</svg>
.sp-draw { color: #f43f5e; }
.sp-draw__line {
stroke-dashoffset: 25; /* static fallback: a three-quarter ring */
}
@media (prefers-reduced-motion: no-preference) {
.sp-draw__line {
stroke-dashoffset: 100;
animation: sp-draw-loop 2s ease-in-out infinite;
}
}
@keyframes sp-draw-loop {
0% { stroke-dashoffset: 100; }
50% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -100; }
}
First half: the circle draws clockwise. Second half: the tail catches up and erases it in the same direction, so the loop reads as one continuous motion. See stroke-dasharray and stroke-dashoffset on MDN.
Note that stroke-dashoffset is a paint-level property, not a transform. It is fine for one small spinner. Do not animate it on dozens of paths at once.
5. Material-style indeterminate arc
The familiar spinner where the arc grows and shrinks as it turns. It combines two animations on different elements: the whole SVG rotates linearly, while the circle inside animates its dash length.
Keeping the two animations on separate elements lets each have its own timing function: linear for the turn, ease-in-out for the grow and shrink.
<svg class="sp-mat" role="status" aria-label="Loading"
viewBox="0 0 50 50" width="48" height="48">
<circle class="sp-mat__arc" cx="25" cy="25" r="20" fill="none"
stroke="currentColor" stroke-width="5" stroke-linecap="round"
stroke-dasharray="90 150" stroke-dashoffset="0"/>
</svg>
.sp-mat { color: #8b5cf6; }
@media (prefers-reduced-motion: no-preference) {
.sp-mat {
animation: sp-mat-rotate 1.4s linear infinite;
}
.sp-mat__arc {
animation: sp-mat-dash 1.4s ease-in-out infinite;
}
}
@keyframes sp-mat-rotate {
to { transform: rotate(360deg); }
}
@keyframes sp-mat-dash {
0% { stroke-dasharray: 1 150; stroke-dashoffset: 0; }
50% { stroke-dasharray: 90 150; stroke-dashoffset: -35; }
100% { stroke-dasharray: 90 150; stroke-dashoffset: -124; }
}
The outer <svg> rotates as a normal CSS box, so transform-origin defaults to its center and no transform-box is needed. The final offset of -124 is just under one full circumference (125.7), which is what makes the loop seamless.
Make them accessible
Name and announce the spinner
All five examples use role="status" and aria-label="Loading" on the SVG. The status role is a polite live region, so screen readers announce its content changes without interrupting what the user is doing.
One caveat: live regions are most reliable when the region already exists in the page and its content changes afterward. If you inject a brand-new element with role="status" and its label at the same moment, some screen readers will not announce it. A sturdier pattern is to keep an empty container in the DOM and put the text in when loading starts:
<div role="status" class="loader">
<!-- JS adds the spinner and this text when loading starts -->
<svg aria-hidden="true" class="sp-arc" viewBox="0 0 50 50" width="48" height="48">
<circle cx="25" cy="25" r="20" fill="none" stroke="currentColor"
stroke-opacity="0.2" stroke-width="5"/>
<circle class="sp-arc__head" cx="25" cy="25" r="20" fill="none"
stroke="currentColor" stroke-width="5" stroke-linecap="round"
stroke-dasharray="31.4 125.7"/>
</svg>
<span class="visually-hidden">Loading</span>
</div>
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
Here the decorative SVG is hidden from assistive tech with aria-hidden="true" and the text carries the meaning. When loading ends, replace "Loading" with a result such as "Results loaded", or remove the container.
Respect reduced motion
The CSS above puts all motion inside @media (prefers-reduced-motion: no-preference). Users who ask their OS to reduce motion get the static drawing. See prefers-reduced-motion on MDN.
A static ring can look like a hang, so a middle ground is a slow, low-amplitude fade instead of movement:
@media (prefers-reduced-motion: reduce) {
.sp-arc,
.sp-dots,
.sp-ring,
.sp-draw,
.sp-mat {
animation: sp-fade 2s ease-in-out infinite alternate;
}
}
@keyframes sp-fade {
from { opacity: 0.4; }
to { opacity: 1; }
}
A slow opacity change gives feedback without spinning, sliding or scaling. If you want to follow WCAG strictly, note that indefinite animation that runs longer than five seconds needs a way to pause it (see WCAG 2.2.2, Pause, Stop, Hide). Loading indicators are often treated as essential motion, but if a spinner can stay on screen for a long time, add a timeout message or a stop control.
Keep spinners cheap
- Avoid
will-changeunless you measured a problem. MDN calls it a last resort. - Unmount spinners when loading finishes. A hidden spinner with
opacity: 0can keep animating. Usedisplay: noneor remove the element so the animation stops.
Generating your own variants
If you want a spinner in a different style, such as a logo that spins or a brand mark that draws itself, you can write a prompt instead of hand-tuning keyframes. SVG Weave's CSS keyframes generator takes an SVG and a description like "stagger the three bars by 0.2s" and writes the @keyframes for it. It is a paid tool, credit-based with packs from $5. Hand-written spinners like the ones above are free, and for a generic loader they are usually enough.
FAQ
Do CSS-only SVG spinners work in all browsers?
CSS @keyframes on SVG elements works in current versions of Chrome, Firefox, Safari and Edge. The one feature to check for older browsers is transform-box; check its compatibility table on MDN if you support legacy browsers. The arc spinner (example 1) can avoid it by rotating the whole <svg> element instead of the inner circle.
Why does my SVG spinner wobble instead of spinning in place?
The pivot is wrong. For SVG elements the default transform reference box is the whole viewBox, so rotating a shape that is not centered in it swings it around the wrong point. Add transform-box: fill-box; transform-origin: center; to the animated element.
Should I use SMIL or CSS for SVG spinners?
CSS for most cases. It is easier to read, can be wrapped in a prefers-reduced-motion query, and works when the SVG is inline in your HTML. SMIL is useful when you need to animate attributes CSS cannot reach, like a path's d, but it is harder to turn off for reduced motion.
Can I use these spinners in an img tag or CSS background?
Yes, as long as the CSS lives inside the SVG file in a style element. Animations run in img and background-image, but the SVG cannot see your page's CSS. That means no currentColor inherited from the page, so hard-code the color, and test the reduced-motion query inside the file in your target browsers.
How do I stop the spinner without removing it?
Set animation-play-state: paused on the animated elements, or add a class that removes the animation. See animation-play-state on MDN.
Do I need a separate label if the SVG has aria-label?
No, one name is enough. If you add visible or visually hidden text inside a role="status" container, as in the sturdier pattern above, hide the SVG with aria-hidden="true" so the same word is not announced twice.
Next steps
Spinners are the easy case. For logos, illustrations and larger animations, read the SVG animation performance and accessibility checklist, or see how to convert a raster logo to SVG first so there is something to animate.