Creating a Material Spinner with Pure and Simple CSS

alekseiberezkin

Aleksei Berezkin

Posted on June 23, 2024

Creating a Material Spinner with Pure and Simple CSS

Everyone has seen it hundreds, if not thousands of times, and it seems like this loader is very easy and natural. However, if you try to create it from scratch, you'll find it surprisingly challenging. The first problem is simply understanding the motions involved.

Rotation and... what?

After observing it for a while, you may notice there are two motions, simple rotation, and something weird happening with the arc ends. Let's remove the first and slow down the second:

Much clearer now. First, the arc's beginning moves forward, then its end catches up with the beginning. But how is this possible to achieve?

SVG circle and stroke-dasharray

The solution relies on the SVG stroke-dasharray property. When applied to an SVG element, such as <circle>, it converts the solid stroke into a dashed one. For example, stroke-dasharray: 10px 20px 30px 40px means the first dash is 10px, the next is a 20px gap, then a 30px stroke, then a 40px gap. This pattern repeats until the full circle is complete:

You might have noticed a usability issue, though: the value items are the arcs lengths in user-space pixels. It can be in some other CSS unit such as % or em, but this is still obscure because we humans measure arcs and angles in degrees. We understand 90° but not 90px.

Fortunately, it's easy to fix this issue by calculating the --1deg custom property, which represents the length of one degree, and using it as a unit:

circle {
  --r: 47px;
  --1deg: calc(2 * pi * var(--r) / 360);

  stroke-dasharray:
    calc(40 * var(--1deg))
    calc(80 * var(--1deg));
}
Enter fullscreen mode Exit fullscreen mode

The result resembles the trace of helicopter blades:

We now have a convenient tool to define arc ends in human-readable units — degrees. The next picture shows the animation phases of the spinner and their corresponding arc values.

Conventions:

  • Gaps are shown in light gray; these are not actual gaps but are used to make them visible.
  • The first and last arcs are not to scale, they are slightly more than 2° for the same reason.

Now we are ready to write it in CSS. Note the additional leading zero, which is needed to skip the initial stroke and start with a gap.


@keyframes dash-anim {
  0% {
    stroke-dasharray:
      0
      0
      calc(2 * var(--1deg))
      calc(358 * var(--1deg));
  }
  50% {
    stroke-dasharray:
      0
      calc(35 * var(--1deg))
      calc(290 * var(--1deg))
      calc(35 * var(--1deg));
  }
  100% {
    stroke-dasharray:
      0
      calc(358 * var(--1deg))
      calc(2 * var(--1deg));
  }
}

circle {
  animation: dash-anim 5000ms ease-in-out infinite;
}
Enter fullscreen mode Exit fullscreen mode

This renders the already familiar slow arc animation:

Fix the jumping arc, or tolerate it?

Because the arc cannot cross the origin of stroke-dasharray, there's a noticeable “jump” between 100% and 0% phases. It is theoretically possible to fix this:

@keyframes dash-anim {
  /* ... */
  100%: {
    /* ... */
    transform: rotate(2deg);
  }
}
Enter fullscreen mode Exit fullscreen mode

Unfortunately it doesn't work very smooth in Firefox — there is annoying blinking in the 0%/100% phase. However, a 2° jump is not that significant. The jump is barely noticeable at full speed and can be safely tolerated.

Implementation notes

stroke-dashoffset

If you inspect the MUI implementation, you will notice they use a 2-component stroke-dasharray together with a negative stroke-dashoffset. The latter effectively functions similarly to a leading zero in stroke-dasharray, creating the leading gap. However, I'm using stroke-dasharray with a leading zero because I find it easier to understand.

The following fragment shows the same animation rewritten with stroke-dashoffset, like in MUI:

@keyframes dash-anim {
  0% {
    stroke-dasharray:
      calc(2 * var(--1deg))
      calc(358 * var(--1deg));
    stroke-dashoffset: 0;
  }
  50% {
    stroke-dasharray: 
      calc(290 * var(--1deg))
      calc(358 * var(--1deg));
    stroke-dashoffset: calc(-35 * var(--1deg));
  }
  100% {
    stroke-dasharray: 
      calc(2 * var(--1deg))
      calc(358 * var(--1deg));
    stroke-dashoffset: calc(-358 * var(--1deg));
  }
}
Enter fullscreen mode Exit fullscreen mode

You may also notice that they do not bother to adjust the stroke-dasharray values that exceed the full circle. For example, in the 50% phase, only 35° of a 358° gap will be visible, and everything else will be trimmed away.

Precalculated values

The MUI spinner doesn't use CSS variables and calculations — all values are precalculated. You may do the same if you target older browsers. The following code represents the same animation given r: 47px.

@keyframes dash-anim {
  0% {
    stroke-dasharray: 2px 293px;
    stroke-dashoffset: 0;
  }
  50% {
    stroke-dasharray: 238px 293px;
    stroke-dashoffset: -29px;
  }
  100% {
    stroke-dasharray: 2px 293px;
    stroke-dashoffset: -293px;
  }
}
Enter fullscreen mode Exit fullscreen mode

Totally cryptic!

Material Web Components

The official Google implementation of the Material spinner is much more sophisticated. It doesn't contain any SVG; instead, its arcs are made with two empty containers using border-radius: 50%, along with four rotation animations. This complexity pays off — the result looks very clean and smooth, and it runs perfectly in all browsers.

And finally

It's time to complete our spinner: speed it up, reintroduce rotation, and let's spice it up with a blurry shadow and fancy colors!

💖 💪 🙅 🚩
alekseiberezkin
Aleksei Berezkin

Posted on June 23, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related