A dreidel spinning on a table

How to stop your spinner from jumping in React

  • Kristofer Giltvedt Selbekk

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