Animating between units with react-spring

Published on November 18, 2019

cover picture

It’s no secret, that on the web we have to deal with different units - from rems and pixels to percentages and viewport-based values. In this tutorial, we’ll explore the problem of animating between different units, and see how we can overcome it.

The problem

Let’s start by creating this simple animation where a div with pixel-based size expands to fill the entire viewport width and height when we click on it:

What we'll create

To create this animation, we’ll use useSpring hook from react-spring package, and set the width and the height of the box to 200px when it’s not expanded, and to 100vh and 100vw when it is. We’ll also remove 10px border-radius when the box is expanded:

import React from "react"
import { useSpring, animated } from "react-spring"
const App = () => {
const [isExpanded, setExpanded] = React.useState(false);
const style = useSpring({
width: isExpanded ? "100vw" : "200px",
height: isExpanded ? "100vh" : "200px",
borderRadius: isExpanded ? "0px" : "10px",
});
return (
<animated.div
className="box"
style={style}
onClick={() => setExpanded(!isExpanded)}
>
I am a box
</animated.div>
);
};
view raw App.tsx hosted with ❤ by GitHub

The result will look like this:

The problem

As we can see, the border-radius animation is working, but the box gets smaller instead. Why is that?

To understand the problem, we need to look at how react-spring (and most of React animation libraries for that matter) handles animation between units. When we pass width and height values as strings, react-spring will parse the numeric values from the “from” and “to” values, take the unit from the “from” value, and completely ignore the unit of the “to” value:

Parsing animated values

In our example, the initial state of the box is collapsed and the height of the box is pixel-based, so when react-spring starts animating it, it’ll use “pixels” as a unit. If instead the initial state was expanded and the height was viewport-based, then the animation would use “vh” as a unit and run from 100vh to 200vh instead.

The border-radius animation works fine because if uses pixels for both expanded and collapsed states.

Side note: the only library that I found that handles animation between units correctly out-of-the-box is framer-motion. I have an intro level article about framer-motion, and a full framer-motion course on Youtube.

The solution

To fix this problem, we need to make sure that both the initial and the target value use the same unit. We can easily convert viewport-based values into pixels with these simple calculations:

const vhToPixel = value => `${(window.innerHeight * value) / 100}px`
const vwToPixel = value => `${(window.innerWidth * value) / 100}px`
view raw utils.js hosted with ❤ by GitHub

Now instead of using viewport-based values, we’ll use our helper functions to set the width and the height of the box:

const style = useSpring({
width: isExpanded ? vwToPixel(100) : "200px",
height: isExpanded ? vhToPixel(100) : "200px",
borderRadius: isExpanded ? "0px" : "10px",
})

This solves the problem only partially because if we resize the browser window after the animation has run, we’ll discover a different issue - the box doesn’t adjust to the viewport size anymore since now it has pixel-based size:

Issue with browser resizing

We can fix this issue by setting the box size back to viewport-based values once the animation finishes. First of all, we’ll use useRef hook to hold a reference to the actual DOM node of our box. Secondly, react-spring provides a handy onRest callback that fires at the end of each animation, so we can use it to check if we animated to the expanded state, and if so, we’ll set the box width and height directly.

import React from "react"
import { useSpring, animated } from "react-spring"
const App = () => {
const [isExpanded, setExpanded] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
const style = useSpring({
width: isExpanded ? vwToPixel(100) : "200px",
height: isExpanded ? vhToPixel(100) : "200px",
borderRadius: isExpanded ? "0px" : "10px",
onRest: () => {
if (isExpanded && ref.current) {
ref.current.style.height = "100vh";
ref.current.style.width = "100vw";
}
}
});
return (
<animated.div
className="box"
style={style}
ref={ref}
onClick={() => setExpanded(!isExpanded)}
>
I am a box
</animated.div>
);
};
view raw App.tsx hosted with ❤ by GitHub

With this setup, animation works fine - it uses pixel values while animating, and sets the box dimensions to viewport-based size upon completion, so the box remains responsive even if we resize the browser afterward.

Final result

You can find working CodeSandbox demo here.

Conclusion

Animation libraries such as react-spring give us a greater degree of control over our animations compared to CSS animations, but they have shortcomings as well. Animating values between units is one of them, and it requires us to do extra work to make sure that our animation runs smoothly and remains responsive.