Creating a Material Spinner with Pure and Simple CSS
Tags: webdev, css, material
Published: 2024-06-23 19:47:28

{%codepen https://codepen.io/wvtyubnf-the-selector/pen/MWpGbre %}

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:

{%codepen https://codepen.io/wvtyubnf-the-selector/pen/vYwdvvr %}

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`](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/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:

{%codepen https://codepen.io/wvtyubnf-the-selector/pen/jOozPRm %}

You might have noticed a usability issue, though: the value items are the arcs lengths in [user-space](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) 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:

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

  stroke-dasharray:
    calc(40 * var(--1deg))
    calc(80 * var(--1deg));
}
```

The result resembles the trace of helicopter blades:

{%codepen https://codepen.io/wvtyubnf-the-selector/pen/dyEmYPB %}

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.

{%codepen https://codepen.io/wvtyubnf-the-selector/pen/JjqpwVr %}

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.

```css

@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;
}
```

This renders the already familiar slow arc animation:

{%codepen https://codepen.io/wvtyubnf-the-selector/pen/vYwdvvr %}

## 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:

```CSS
@keyframes dash-anim {
  /* ... */
  100%: {
    /* ... */
    transform: rotate(2deg);
  }
}
```

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](https://mui.com/material-ui/react-progress/), you will notice they use a 2-component `stroke-dasharray` together with a negative [`stroke-dashoffset`](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/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:

```CSS
@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));
  }
}
```

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`.

```CSS
@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;
  }
}
```

Totally cryptic!

### Material Web Components
The official Google implementation of the [Material spinner](https://material-web.dev/components/progress/) 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!

{%codepen https://codepen.io/wvtyubnf-the-selector/pen/GRaxZJP %}