Recently I was talking with a designer on my team about her vision for a redesign of a certain part of the Codecademy site. She demoed some very sleek, somewhat elaborate transitions she had been exploring. I immediately started wondering how complex they’d be to implement in our app, which is built in React. Would I have to load in multiple new libraries? Was it even possible in React to do some of the seamless, sequenced animations of elements from one state to another that she was envisioning, without requiring incredibly complex code or awkward hacks?
Most of the time, when you want an animation in your React app, it makes sense to reach for one of the many libraries that abstract away the details and let you use a declarative style. But sometimes those libraries don’t get you all the way there.
Maybe you (or the designer you work with) envision a complex series of sequenced animations of multiple elements when a parent component enters and exits the DOM. Maybe you want to animate positions of certain elements as they are re-rendered in different parts of the app. Or maybe you want to take control over the transitions of a continually updating list, a la d3’s enter/update/exit sequence.
It turns out to be surprisingly feasible to create more complex animation effects in your own code without having to contend with the possible limitations of a React-friendly animation library. You will, however, have to be ok with going slightly against the grain of React. In the examples below, we’ll hook into lifecycle events, making components “impure” by occasionally delaying updates in order to animate elements from one state to another. We’ll also be reaching into the DOM directly using refs.
Manipulating the component lifecycle
To create certain types of animations, we’ll need to interfere with different parts of the component lifecycle. If we want to animate the removal of elements, for instance, we’ll need a way to delay the removal of nodes from the DOM for the duration of the exit animation. To animate element transitions from point A to point B across the screen, we’ll need to calculate node beginning and ending positions right before the browser paints the change to the screen, and manage layout transitions ourselves.
Luckily, React offers a variety of lifecycle methods that we can hook into. The method
shouldComponentUpdate, which fires when a component receives new props or state, allows us to delay DOM updates until after an exit animation has finished running. If on the other hand we plan to transition elements from one part of the screen to another, we’ll need a way to record the previous positions of the elements right before they are updated. We can use two methods — either
shouldComponentUpdate to record the previous positions of elements, and then
componentDidUpdate to reposition them and smoothly transition them to their new places.
Accessing DOM nodes with refs and data-attributes
We’ll use a ref on the parent container to access DOM nodes so that we can animate them. Even though in the examples below the goal is to keep the animation logic and the components fairly decoupled, there are a few caveats.
First, the wrapped container component must be class-based rather than a functional component, so that we can access its DOM element via a ref. Second, some animations will also require each child to have a unique
data-* attribute. (I’ll use
data-id.) This attribute will help our animation function distinguish between the children elements and process them correctly.
Separating animation concerns into a higher order component
In the examples I’ve used higher order components to separate lifecycle animation logic from the presentational logic of components. This is just one possible way of organizing things, and it adds a bit of complexity when it comes to refs. However, the pattern I’ve used in the examples seems to work fairly well.
It consists of three parts:
- The presentational components, here a
Itemcomponent. They are mostly unaware of the animations, except for two things — as mentioned above the parent
Listhas a ref, so it can’t be functional, and the child
Itemneeds to render a
- The animation functions in their own file. I’m using my favorite vanilla JS animation library, anime.js for the actual DOM manipulation. But you could use another JS animation library or CSS transitions and animations.
- The higher order component that wraps the parent
Listcomponent and is passed the animation functions. At various parts in the component lifecycle, it calls the appropriate animation function with a ref to the wrapped
That’s a high-level overview of how the code is structured. Now, let’s look at the examples!
One: A basic enter/exit animation
This example is like a bare-bones version of a ReactTransitionGroup that is optimized for calling imperative animations to manage transitions. Here, the parent toggles the
isVisible prop to show and hide the child, and the
animatingOut state variable makes sure that, even if other updates trigger
shouldComponentUpdate before the animation has completed, the exiting elements will be kept in the DOM long enough to transition gracefully.
I have a
setInterval call here updating the element items independent of the entry and exit animations, so that you can see that this setup can handle unrelated updates without hindering the progress of the main animation.
The HOC wrapper component looks like this:
Two: Animating component positions
This example is more complex and provides a better justification for the hand-rolled animation approach. In this animation, there are no elements entering or leaving the DOM. Instead, we want to catch the moment when the groupings have changed, and animate the positions of the children as they move to their new places. For this type of animation, we can use the FLIP technique:
- Record the positions of children right before they are reorganized
- Wait for the browser to run layout
- Apply transforms to the children in
componentDidUpdate, before the changes have been painted, that cause them to return to their pre-update positions
- Initiate transitions to gradually move them back to their updated places
Below is an example of the animation function that is called twice by the HOC: initially in
componentWillReceiveProps, where it stashes the initial positions of the components and returns another function to be called in
componentDidUpdate, that will actually handle the transitions with anime.js. As with all of the examples, the component is passed
List, the parent ref, by the HOC.
Three: D3-style enter, update, and exit animation
This example combines techniques from the first two — this time, not only are we animating in new elements and delaying updates until we can animate out exiting elements, but we are also waiting for
componentDidUpdate to run layout so that we can smoothly animate the updating items to their new positions in the list before the new items appear. The
shouldComponentUpdate function is responsible for sorting ids into the three categories of enter, update, and exit, and passing those lists of ids to the respective animation functions, which use the
data-id attribute mentioned above to filter the items to the list that they need to animate.
If you check out the example in storybook, you’ll see a continually updating number rendered under the animation. This is a prop that is being passed through the animation HOC into the
List component. Its purpose is to demonstrate that, when lifecycle events are correctly handled, exit animations can proceed as normal even when other sources of data are updating. The way the HOC facilitates this is by caching the old list of elements for the duration of the exit animation.
The code in the animations file is a also the most complex out of the three examples, partly because I try to account for some edge cases — like fast-forwarding in-progress animations when a new update occurs before the current animation has had a chance to complete.
It could still stand to be refined, but if you’d like to look at the current implementation for this example, you can view the code here.
Interested in working for Codecademy? Check out our jobs page!