Sometimes, when loading data in a web app, it happens in this waterfall-y approach. First, you fetch some auth data, then some user data, and finally the data required to build your view.
This can often lead to different spinners being rendered in the same place, leading to the following user experience:
See how that spinner kind of "jumps" back to start every time the text changes? I hate that! Granted, this issue will probably disappear once we can use Suspense for everything - but until then I'd love to fix this for our customers.
This "jump" happens because a new spinner is mounted to our DOM, and the CSS animation is started anew.
A few weeks ago, React Native DOM author Vincent Reimer posted this little demo:
I was amazed! 🤩 Is this even a possibility? How would you even do that?
After staring in bewilderment for a few minutes, I started digging into how this could be achieved. And as it turns out, it's a pretty simple trick!
How to sync your spinners
The moving parts of spinners are typically implemented with CSS animations. That's what I did in the example above, at least. And that animation API is pretty powerful.
The animation-delay
property is typically used to orchestrate CSS animations, or stagger them one after another (first fade in, then slide into place, for example). But as it turns out, it can be used to rewind the animation progress as well - by passing it negative values!
Since we know how long our spinner animation loop is, we can use negative animation-delay
values to "move" the animation to the correct spot when our spinner mounts.
Given the following CSS:
keyframe spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: 1000ms infinite spin;
animation-delay: var(--spinner-delay);
/* visual spinner styles omitted */
}
We can set the animation delay when our spinner component mounts:
const Spinner = (props) => {
const mountTime = React.useRef(Date.now()));
const mountDelay = -(mountTime.current % 1000);
return (
<div
className="spinner"
aria-label="Please wait"
style={{Â '--spinner-delay': `${mountDelay}ms` }}
/>
);
};
Here, we use React's useRef
hook to save the point in time our Spinner
component mounted. We then calculate the amount of milliseconds to "rewind" our spinner animation, and make that value negative.
Finally, we pass down the --spinner-delay
CSS custom property via a style prop.
Here's the result:
More detail please
If you want a step-by-step on what happens here? No worries, here it is. In excruciating detail. 🙈
const mountTime = React.useRef(Date.now()));
The function Date.now()
returns the amount of milliseconds from January 1st, 1970 (see here for a deeper dive into why that is). We're going to use that number as a baseline for where our animation will be when it mounts.
The React.useRef
hook lets you save an arbitrary value without triggering a re-render. It's perfect for saving stuff like our "mount time". You can see the documentation) for more details about this function.
const mountDelay = -(mountTime.current % 1000);
The mountDelay
constant is the actual number of milliseconds we're going to "rewind" our animation. The number 1000
must match the amount of milliseconds the animation runs for - so if your spinner spins slower or quicker than the one in this example, you will have to adjust this number.
We're accessing the value calculated in mountTime
by accessing the current
property of the mountDelay
ref. This is how React refs are structured.
We're using the modulo operator %
to figure out how many milliseconds out into our animation we are. If you're not familiar with the %
operator, that's fine. If you do 1123 % 1000
, you get 123. If you do 15 % 15
, you get 0. You can read more about it here.
Finally, we're negating the number, since we want a negative delay value to pass into the animation-delay
property.
<div style={{Â '--spinner-delay': `${mountDelay}ms` }} />
Did you know you can pass in CSS custom properties (formerly known as CSS variables) to your classes via the style
prop? Yeah, me neither! Turns out, that's actually a pretty nifty technique to pass dynamic values to our CSS. Note that we're suffixing our millisecond value with ms
before passing it in.
You can read more about custom properties on MDN.
keyframe spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: 1000ms infinite spin;
animation-delay: var(--spinner-delay);
}
In our CSS, we specify our animation via the animation
property, and then we specify the animation-delay
value separately. You could do this in the animation
declaration as well, but this is a bit more readable to me.
And that's it!
I hope you use this technique to improve your spinners, and share it with your friends. Thanks for reading 👋
Top comments (6)
This is a solid solution, albeit feels a bit over engineered. Don't think the user needs to know every detail that's happening in the background, so using generic strings with set delays in the component would be my preferred way to go.
Hi Priit!
Thanks for your comments. The details I show is just for show, and to emphasise when each loader is switched out for another one. I'm not sure how you'd do this with set delays, as you don't know when each loader will unmount / re-mount. I'd love to see an example if there's a more elegant way to do it! 🤗
I had no idea you could actually pass in CSS variables via the style prop!
I'm so gonna play with that later!
Nice technique!
Had to adapt your solution slightly for my case:
Not 100% sure, but:
I believe that your method sets the animation delay when the node is mounted in the Virtual DOM. This method instead sets the animation delay when the node is mounted in the actual document.
This was perfect! 100% correct here. Thank you!
Wow! I learned a lot, thanks for sharing.