That Dang Material Design Spinner in One Element

tigt

Taylor Hunt

Posted on February 12, 2022

That Dang Material Design Spinner in One Element

You know the one, looks like this:

A thick colored circle outline with ¼ missing. If animated, it’d spin counter-clockwise and the missing segment would grow and shrink.

material.io/components/progress-indicators#circular-progress-indicators

I was going to show it animated, but dev.to removes the controls attribute on <video> so you can’t pause it, and that’s terrible.

I want the markup to be as simple as:



<progress></progress>


Enter fullscreen mode Exit fullscreen mode

…because that’s the right tool for the job, dangit. It’s easy, accessible, and semantic.

CSS is powerful enough to style <progress> into all sorts of fancy loading indicators, 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… was the worst. But, sadly, also the most official. I expected an abundance of <div>s because “modern web development” but not this many:



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


Enter fullscreen mode Exit fullscreen mode

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

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

  • 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, which can hiccup animations.

SVG?

Okay, so the official implementations were out. The next best place to steal front-end code? CodePen.

And thanks to Fran Pérez's Material Design Spinner pen, 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:

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

  2. Risks desyncs and sputtery animation in browsers that don’t hardware-accelerate SVG animations — 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 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:

Final 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, 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! 🎉

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



// (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)) + '%'
}


Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
tigt
Taylor Hunt

Posted on February 12, 2022

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

Sign up to receive the latest update from our blog.

Related