You know the one, looks like this:
I want the markup to be as simple as:
<progress></progress>
…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 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>
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 animateopacity
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:
Annoying to update — tweaking the code involves decoding the URI, understanding the SVG, then re-encoding to a mini SVG data URI
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.
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)) + '%'
}
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.)
Top comments (2)
Hey thanks for crediting my for my spinner, this was a long time ago :)
Shoulders of giants, etc.