SVG loading spinner

You can create a spinner by animating the stroke properties of a SVG circle element.

See the Pen SVG circle spinner (css keyframes only) by IpsumLorem16 (@ipsumlorem16) on CodePen.

If you are new to SVG animation this post would be a good place to start: An Introduction to SVG Animation

How it’s made

The two main parts of the SVG, are a couple of circle elements on top of each other, both with transparent fill and 6px stroke. The .ring-track is static and just sits behind the spinner for decoration, while the spinner .loader-ring rotates and changes length.

This might seem a bit confusing if you are unfamiliar with this method, here is useful article by CSS tricks, that will help you understand how svg-line-animation-works

<circle 
  class="ring-track" 
  fill="transparent"
  stroke-width="6px"
  stroke="#9c9c9c30"
  cx="50" cy="50" 
  r="44" 
/>
<circle 
  class="loader-ring" 
  fill="transparent"
  stroke-width="6px"
  stroke="#ec5c0e"
  stroke-dashoffset="276.460"
  stroke-dasharray="276.460 276.460"
  cx="50"
  cy="50"
  r="44" 
/>

Setting the size of the stroke-dasharray to the same radius of .loader-ring (or larger) completely covers it with one long dash. The second value adds a gap of equal length. So it appears you have the outline of a circle.

And setting stroke-dashoffset to the same value, pushes the dash along so it is no longer visible.

Animating

The idea is simple, vary the width of .loader-ring, while also rotating it clockwise.

stroke-dashoffset

To vary the width of the spinner .loader-ring you can set the stroke property dash-offset.
Full-width would be stroke-dashoffset="0", and fully hidden stroke-dashoffset="276.460".

So I created two keyframe animations, one to first ‘load’ the spinner into view: starting-fill, and one to ‘randomly’ vary the width of it: vary-loader-width

svg .loader-ring {
  animation: 
    starting-fill 0.5s forwards,
    vary-loader-width 3s 0.5s linear infinite alternate;
}
@keyframes starting-fill {
  to {
    stroke-dashoffset: 270;
  }
}
@keyframes vary-loader-width {
  0% {
    stroke-dashoffset: 270;
  }
  50% {
    stroke-dashoffset: 170;
  }
  100% {
    stroke-dashoffset: 275;
  }
}

The vary-loader-width animation will play forwards then backwards forever, but only after starting fill has completed, because of the 0.5s delay added.

Spinning

To get the SVG circle element spinning on the right axis you first need to set the correct transform-origin. This needs to be XY co-ordinates in pixels from the top left of the SVG. For the circle which is dead center, it is transform-origin: 50px 50px, same as it’s cx="50" cy="50" position attributes.

Now it can be rotated without it going wonky, lets add that animation onto it:

svg .loader-ring {
  transform-origin: 50px 50px;
  animation: 
    starting-fill 0.5s forwards,
    vary-loader-width 3s 0.5s linear infinite alternate,
    spin 1.6s 0.2s linear infinite;
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

That’s it, for the basic animation. Getting it going was the easy part. What took me a while to get right was tweaking timings and stroke-dashoffset amounts to make it look somewhat interesting. I used a trial and error approach, but it might be easier to study other animations to see how they move.

Adding more

You might have noticed in the pen you can click the spinner to ‘finish loading’. Where it fills up entirely, and then fades away.

Adding this effect, I could not find an easy way to smoothly transition the current width of the .loader-ring to full width. It would always quickly shrink back down to zero before completing, not ideal.

A hacky solution was to add a duplicate spinner .loader-ring-overlay that is hidden until needed. Filling the circle and then fading out the svg. That turned out pretty smooth.

<circle 
  class="loader-ring-overlay" 
  fill="transparent"
  stroke-width="6px"
  stroke="#ec5c0e"
  stroke-dashoffset="276.460"
  stroke-dasharray="276.460 276.460"
  cx="50"
  cy="50"
  r="44" 
/>

As you can see it is clone of the other circle, but currently hidden, and with only the spin animation running (so it is in sync with .loader-ring).

svg .loader-ring-overlay {
  visibility: hidden;
  transform-origin: 50px 50px;
  animation: spin 1.6s 0.2s linear infinite;
}

In this demonstration when the spinner is clicked, the class .complete is added to a parent. That ‘finishes loading’ the loader, and fades it out. A timeout is also triggered to run when it is complete, removing the SVG from the DOM.

const loader = document.querySelector("svg");
// on clicking svg spinner, 'finish loading'
loader.addEventListener("click", e => {
  document.body.classList.add("complete");
  setTimeout(() => {
    loader.classList.add("hidden");
    button.classList.remove('hidden');
  }, 900);
});
.complete .loader-ring-overlay {
  visibility: visible;
  animation: 
    complete-fill 0.5s linear forwards, 
    spin 1.6s 0.2s linear infinite;
}
.complete .loader-ring {
  animation: 
    starting-fill 0.5s forwards,
    vary-loader-width 3s 0.5s linear infinite alternate,
    spin 1.6s 0.2s linear infinite, fade 0.1 0.5s linear forwards;
}
.complete svg {
  animation: fade 0.2s 0.7s linear forwards;
  transition: all 0s 0.9s;
  cursor: initial;
  pointer-events: none;
}

I also added a reset button, that fades in, so you can play it over and over.