That Dang Material Design Spinner in One Element
Tags: css, html, performance, webdev
Published: 2022-02-12 21:11:20

---
title: That Dang Material Design Spinner in One Element
description: "Consarnit, today’s CSS should be powerful enough to animate a funny looping circle — and it is!"
published: true
tags: css, html, performance, webdev
cover_image: https://dev-to-uploads.s3.amazonaws.com/i/2pb3iwjeka9u3westcxk.png
---

You know the one, looks like this:

<figure>
<p>
<a href="https://material.io/components/progress-indicators#circular-progress-indicators"><img alt="A thick colored circle outline with ¼ missing. If animated, it’d spin counter-clockwise and the missing segment would grow and shrink." src="https://dev-to-uploads.s3.amazonaws.com/i/a5jdechf7ifrio3qh00s.png" width="176" height="176"></a>
</p>

<figcaption>
<a href="https://material.io/components/progress-indicators#circular-progress-indicators">material.io/components/progress-indicators<b>#circular-progress-indicators</b></a>

<p>I was going to show it animated, but dev.to removes the <code>controls</code> attribute on <code>&lt;video&gt;</code> so you can’t pause it, and that’s terrible.</p>
</figcaption>
</figure>

I want the markup to be as simple as:

```html
<progress></progress>
```

…because [that’s the right tool for the job, dangit](https://codepen.io/tigt/post/semantic-single-element-loading-spinners-with-html-css). It’s easy, accessible, and semantic.

CSS is powerful enough to [style `<progress>` into all sorts of fancy loading indicators](https://codepen.io/tigt/pen/ddXgoX?editors=1100), so it _should_ also be able to animate Google’s funny looping circle.

## Existing implementations

The first thing I did was look for code to steal. You know, like a developer does.

### Material Design Lite

Surely Google has its own spinner for its sites, right? Who better to rip off than the inventor?

[Material Design Lite’s Spinner component](https://getmdl.io/components/index.html#loading-section/spinner)… was the worst. But, sadly, also the most official. I expected an abundance of `<div>`s because <img alt="“modern web development”" src="https://dev-to-uploads.s3.amazonaws.com/i/yokbmigzviyne7zkhcvd.png"> but not _this_ many:

```html
<div class="mdl-spinner mdl-spinner--single-color mdl-js-spinner is-active is-upgraded"
  data-upgraded=",MaterialSpinner">
  <div class="mdl-spinner__layer mdl-spinner__layer-1">
    <div class="mdl-spinner__circle-clipper mdl-spinner__left">
      <div class="mdl-spinner__circle"></div>
    </div>
    <div class="mdl-spinner__gap-patch">
      <div class="mdl-spinner__circle"></div>
    </div>
    <div class="mdl-spinner__circle-clipper mdl-spinner__right">
      <div class="mdl-spinner__circle"></div>
    </div>
  </div>
  <div class="mdl-spinner__layer mdl-spinner__layer-2">
    <div class="mdl-spinner__circle-clipper mdl-spinner__left">
      <div class="mdl-spinner__circle"></div>
    </div>
    <div class="mdl-spinner__gap-patch">
      <div class="mdl-spinner__circle"></div>
    </div>
    <div class="mdl-spinner__circle-clipper mdl-spinner__right">
      <div class="mdl-spinner__circle"></div>
    </div>
  </div>
  <div class="mdl-spinner__layer mdl-spinner__layer-3">
    <div class="mdl-spinner__circle-clipper mdl-spinner__left">
      <div class="mdl-spinner__circle"></div>
    </div>
    <div class="mdl-spinner__gap-patch">
      <div class="mdl-spinner__circle"></div>
    </div>
    <div class="mdl-spinner__circle-clipper mdl-spinner__right">
      <div class="mdl-spinner__circle"></div>
    </div>
  </div>
  <div class="mdl-spinner__layer mdl-spinner__layer-4">
    <div class="mdl-spinner__circle-clipper mdl-spinner__left">
      <div class="mdl-spinner__circle"></div>
    </div>
    <div class="mdl-spinner__gap-patch">
      <div class="mdl-spinner__circle"></div>
    </div>
    <div class="mdl-spinner__circle-clipper mdl-spinner__right">
      <div class="mdl-spinner__circle"></div>
    </div>
  </div>
</div>
```

That’s 29 — _count ‘em, **twenty-nine**_ — `<div>`s.

I was able to steal some variables like animation timings from this implementation, but mostly it made me wonder how Google can bang the performance drum and also suggest we use code like this.

[Polymer’s `<paper-spinner-lite>` gets it done with only 7 elements](https://www.webcomponents.org/element/@polymer/paper-spinner/demo/demo/index.html) _and_ allows customization, so I don’t think the inherent design is something inexpressible in CSS. Its `<paper-spinner>` is still 22 `<div>`s, though, and [its only difference is it doesn’t cycle through colors](https://github.com/PolymerElements/paper-spinner/blob/43e9a23634c629278329a0d3666ad447232c2f1d/paper-spinner.js#L28-L30):

> The default spinner cycles between four layers of colors; by default they are blue, red, yellow and green. It can be customized to cycle between four different colors. Use `<paper-spinner-lite>` for single color spinners.

To be fair, MDL/Polymer’s spinners probably had some requirements I don’t:

- They date from at least 2015, so the techniques I use may have had spottier browser support back then. (In particular, it [looks like Safari did not animate `::after` pseudo-elements inside shadow roots](https://github.com/PolymerElements/paper-spinner/pull/89).)

- Google probably had to match their official design specs _perfectly_ — which I don’t much care to. As far as I’m concerned, if my users stare at a spinner long enough to notice animation inconsistency, the page has a bigger problem.

- I think the reason the 4-color version has quadruple the `<div>`s is because they animate `opacity` for hardware-accelerated color changes, because [changing colors on the Web triggers paint invalidation](https://csstriggers.com/), which can hiccup animations.

### SVG?

Okay, so the official implementations were out. [The next best place to steal front-end code? CodePen.](https://codepen.io/search/pens?q=material+spinner)

And thanks to [Fran Pérez's Material Design Spinner pen](https://codepen.io/mrrocks/details/EiplA), I had a starting point that I forked and rewrote to fit my harebrained sensibilities. I then embedded the SVG as a `data:` URI, in order to style my lone `<progress>` element with it:

{% codepen https://codepen.io/tigt/pen/PLWyZL default-tab=css,result %}

I almost went with this, but it had problems:

1. Annoying to update — tweaking the code involves decoding the URI, understanding the SVG, then re-encoding to a [mini SVG data URI](https://www.npmjs.com/package/mini-svg-data-uri)

2. Risks [desyncs and sputtery animation in browsers that don’t hardware-accelerate SVG animations](https://oreillymedia.github.io/Using_SVG/extras/ch19-performance.html#animation-performance-properties-section) — which used to be everything but Firefox and IE, but thankfully I believe Chrome might have that fix in the works.

3. Nothing shows if High-Contrast Mode is on or images are turned off, because it’s a `background-image`.

### The `clip-path` one I ultimately riffed on

I then found [a spinner by Adam “acronamy” Crockett](https://codepen.io/acronamy/pen/bNRJyP) which used `clip-path`. It didn’t _quite_ match the shape and timing needed, but it showed it could be done with only one element and a single `@keyframes` rule:

{% codepen https://codepen.io/acronamy/pen/bNRJyP default-tab=css,result %}

<!-- my forked version: https://codepen.io/tigt/details/61423edf273536d8595136872fa7a18e -->

## Final result

{% codepen https://codepen.io/tigt/pen/JjXjKEP default-tab=css,result %}

### Features

- Tweak the thickness by changing the `border-width`
- Tweak the color with the `color` property. You can even animate color changes that way!
- Tweak the size with the `font-size` property
- Builtin accessible name/caption via `<label>`
- Bonus: backdrop circle that matches the top one

### But should you use it?

Not if what you’re loading behind the spinner runs on the main thread, like React or another JS-heavy thing. If it’s only a `fetch` that inserts some HTML, like [Hotwire’s Turbo](https://turbo.hotwired.dev/), then yeah, go ahead.

Since this was originally code I wrote for work almost three years ago, I thought I wouldn’t need this section anymore, because in the meantime [Chrome shipped hardware acceleration for `clip-path`](https://developer.chrome.com/blog/hardware-accelerated-animations/)! 🎉

Unfortunately, the world isn’t just Chrome. If the animated `clip-path` (and `color` if you want the fancy color-shifting one too) run on the main thread, you can run into a heap of problems:

- Any JS work/re-layouts/etc. can make the animation stutter and hitch.
- If it can’t run on [the accelerated compositor thread](https://developers.google.com/web/fundamentals/design-and-ux/animations/animations-and-performance#css_vs_javascript_performance), it chews up more battery life.
- Since spinners show while the main thread is busy with whatever you needed a loading animation for, a spinner running on the main thread delays whatever’s behind it!

So be careful, and make sure you’re being responsible to your users before reusing code from some dork’s blog post. You know what’s better than a spinner? Showing content faster.

(Was it irresponsible of me to publish this? Yes, probably.)

### Failing to math for “fun” and “profit”

I may have gotten a 4 in AP Calculus, but I managed to forget all of it because I couldn’t even express a circular rotation animation the “right” way. Ultimately, I hacked it by dividing the element into quadrants, and sweeping a side of each `polygon()` as part of the animation.

That probably didn’t make any sense, did it? Here’s the code:

```js
// (x,y) points expressed in %, for use in CSS’s `clip-path`
const center = ['50%', '50%']
var top = ['50%', '-50%']
var right = ['150%', '50%']
var bottom = ['50%', '150%']
var left = ['-50%', '50%']

// These four are used once each for the final “sliver” in the 4 different orientations
const topLeft = ['0%', '-50%']
const bottomLeft = ['-50%', '100%']
const bottomRight = ['100%', '150%']
const topRight = ['150%', '0%']

// Edit these if you want to change the animation.
// It’s a list of `clip-path` coordinates that the animation uses as keyframes.
const keyFrames = [
  [bottom, left, left, top],
  [left, left, left, top],
  [topLeft, topLeft, topLeft, top],

  [top, top, top, right],
  [top, top, right, bottom],
  [top, right, bottom, left],

  [right, right, bottom, left],
  [bottom, bottom, bottom, left],
  [bottomLeft, bottomLeft, bottomLeft, left],

  [left, left, left, top],
  [left, left, top, right],
  [left, top, right, bottom],

  [top, right, right, bottom],
  [right, right, right, bottom],
  [bottomRight, bottomRight, bottomRight, bottom],

  [bottom, bottom, bottom, left],
  [bottom, bottom, left, top],
  [bottom, left, top, right],

  [left, left, top, right],
  [top, top, top, right],
  [topRight, topRight, topRight, right],

  [right, right, right, bottom],
  [right, right, bottom, left],
  [right, bottom, left, top]
]

var cssText = `/* <generated-keyframes> */
@keyframes inchworm {
${keyFrames
    .map((f, i) => {
      const isLastFrame = i === keyFrames.length - 1
      const interval = 1 / keyFrames.length
      const percent = toPercent((i + 1) * interval)
      return `  ${isLastFrame ? '0%,' : ''}${percent} { clip-path: ${polygon(f)} }`
    })
    .join('\n')}
}
/* </generated-keyframes> */`

function polygon (points) {
  const cssPoints = points.map(point => point.join(' ')).join(', ')
  return `polygon(${cssPoints}, ${center.join(' ')});` // final point is always the center
}

function toPercent (num) {
  const percent = num * 100
  const decimalPlaces = Number.isInteger(percent) ? 0 : 3
  return parseFloat(percent.toFixed(decimalPlaces)) + '%'
}
```

If that didn’t make sense to you either, well… yeah, me too. It’s been a while since I wrote it.

(I tried writing that in Sass, but couldn’t manage it.)