DEV Community

Cover image for How to stop your spinner from jumping in React
selbekk
selbekk

Posted on • Edited on • Originally published at selbekk.io

How to stop your spinner from jumping in React

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 */
}
Enter fullscreen mode Exit fullscreen mode

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` }}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

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()));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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` }} />
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
priitpu profile image
Priit • Edited

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.

Collapse
 
selbekk profile image
selbekk

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

Collapse
 
anras573 profile image
Anders Bo Rasmussen

I had no idea you could actually pass in CSS variables via the style prop!
I'm so gonna play with that later!

Collapse
 
wouterraateland profile image
Wouter Raateland

Nice technique!
Had to adapt your solution slightly for my case:

function Spinner() {
  return (
    <div
      ref={(n) => n?.style.setProperty("--delay", `${-(Date.now() % 1000)}ms`)}
      className="spinner"
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Collapse
 
colby profile image
Colby Thomas

This was perfect! 100% correct here. Thank you!

Collapse
 
rhinoandre profile image
André Rhino

Wow! I learned a lot, thanks for sharing.