Animations with React Router
When used correctly, animations can greatly improve the user experience. But animating on a page transition which is handled by React Router can be a huge pain — one that we’ve recently had to deal with at Onfido.
This post is further to a talk I gave at JS Monthly, but I’ll go into a bit more technical detail here.
The end result we’re going for is something like this:
The Challenge
First of all, let’s explore why using React Router makes animating inherently hard.
React Router is designed to immediately update the view-tree according to the current page location. When moving from Page A → Page B, React Router does not provide any hooks to keep Page A visible while it animates out. It immediately removes it from the DOM, once its route is no longer active.
There are some naive solutions that immediately jump to mind, but they have hefty drawbacks:
Naive Solution #1
Solution: Animating Page A manually before the actual history change.
Problem: Every single route away from Page A needs to trigger this animation hook, otherwise animation will not occur. Navigation using the browser directly (e.g. using the back button) will result in no animations.
Naive Solution #2:
Solution: Remove Page A and Page B from the main React Router code, manually listen to the history API, and trigger animations accordingly.
Problem: This will work, but your code will be horrible. Some of your routes will be using React Router, and some will handled manually. Even if you manage to make your custom routing code clean, it’s still two different routing solutions in one codebase — which is, at best, confusing.
Official Solution
So what’s the official recommendation? The React Router documentation recommends using react-transition-group
.
react-transition-group
is a small library that can help with handling animations using React. It also has a component called TransitionGroup
which triggers exit/enter animations on components as they attempt to exit/enter the view-tree.
Using this library with React Router gives us code like this:
Let’s quickly break down the code here, so we know what’s happening:
<Route render={({ location }) => (...)} />
— this does nothing, other than give us easy access to thelocation
object, which we need later on.<TransitionGroup>
— this component will interact with any directTransition
orCSSTransition
children — either by animating them in when they attempt to enter the view-tree, or animating them out when they attempt to exit the view-tree. This component is what’s doing all the heavy lifting for us.<CSSTransition key={location.pathname} classNames="fade" timeout={600}>
— we give akey
based on our current location. This means whenever thelocation.pathname
changes (i.e. our page changes) then React sees thisCSSTransition
element as being a “new” element, different to the last one. This causesTransitionGroup
to animate out the old one, and animate in the new one.<Switch location={location}>
— because we actually have 2Switch
elements on the view-tree at the same time (sinceTransitionGroup
is holding onto our previous page to animate it out, and the previous page and current page both haveSwitch
s), we need to manually pass in the correctlocation
to each one (since one needs the old one, and one wants the new one.
Great! So this is everything we need, right? Unfortunately, not quite. This TransitionGroup
solution is awesome, but falls short in a few ways:
- Multiple page changes: in the Code Sandbox above, there’s a CSS selector to colour the page red if there are more than 2 pages active at any point. If you change page twice quickly, you’ll see that there end up being 3+ pages visible at any one time. This is because
TransitionGroup
has no idea that you’re trying to route from Page A to Page B. All it knows is to animate out old elements before removing them, and that’s all it does. This might be fine for your use-case, but it also might be a deal-breaker (or at least a source of pain). - Timing: if you want Page B to start animating in after Page A has animated out, then you’re going to have a bad time.
TransitionGroup
has no API to specify for animations to happen after some event. Similar to (1), allTransitionGroup
understands is that when an element is added to the view-tree, it should animate it in. - Customisation: both (1) and (2) are symptoms of the larger issue, that
TransitionGroup
lacks any way to customise behaviour for a routing use-case.TransitionGroup
is really designed for adding/removing elements from a list — and it excels at this. In this routing use-case however, its API is simply not powerful enough.
So really TransitionGroup
is just designed for other contexts. But it’s so close! How does it work? Maybe we can scavenge its code.
Turns out that the trick involved here is… just not rendering your children
prop. Instead, store it in your local state, and render from that state. This way, we can continue to render our previous child out while React Router actually wants us to be rendering the new child.
This is a super smart idea… but also super simple! We can definitely scavenge that idea.
Better Solution
A simple implementation of this is pretty achievable, so let’s just get some basic sliding animations. This solution will have the following properties:
- Only 1 page being shown at a time
- Page B won’t animate in, until Page A has finished animating out
This is the end code we’re aiming for, but let’s break it down step by step.
Rendering
Our first step is to render our previous child until it slides off to the left, even when React Router wants us to render our new child.
So here you can see we’re saying prevChild || curChild
, or in other words: “If React Router is telling us to render a new child, ignore it and render the old child, until we’ve finished everything we’re doing with the old child”
The only other thing of interest here is the Slider
class, which I won’t dive too deeply into. It’s a very simple element, that takes 2 props:
position
: the container will animate depending on the direction you tell it, e.g.position={Slider.FROM_LEFT}
would start on the left side of the screen, and animate sliding into the centre of the screen.animationCallback
: a callback that will be invoked whenever a sliding animation has completed.
If you’re curious about how Slider
works, its code is in the CodeSandbox.
Animating on Page Change
The next step is to actually initiate these animations whenever the page changes.
componentDidUpdate
will get called whenever any prop (including the children
prop) changes.
So the primary code of interest here is the if
statement. This determines if the page has changed by seeing if the uniqKey
of each page changes. We need this unique key since it’s hard in React to determine if an element is “new” or just the same element on a new render (with new props etc.). We will provide a unique key later, so let’s take it as a given for now.
When the key has changed, it means the page has changed, so we need to do the following things:
- Save our old page as
prevChild
so we will continue to render it - Set our new page in state too, so we have access to it for later
- Set
position={Slider.TO_LEFT}
so our animation component will start animating the page from the centre of the page, over to the left - Set the
animationCallback
sothis.swapChildren
gets called once the animation is completed
Swap the Children
Once the animation is complete, we trigger this.swapChildren
. At this point, our page is off screen — it’s animated off to the left so far, it is no longer visible. So here, we can swap the page element and begin the new animation.
We get rid of our prevChild
so our current child (the new page that React Router actually wants us to render) starts being rendered.
We also set the position
to FROM_RIGHT
which will cause our element to suddenly jump from the left side of the screen, to the right side of the screen. It will then start to animate in from the right side of the screen, to the centre.
This CodeSandbox should demonstrate more clearly the flow of events:
Integrating with React Router
The above code is all we need to create a SlideOut
component that performs a slide animation whenever its uniqKey
changes. We can then use this with React Router like so:
But we can also do one better, and make ourselves a Higher-Order Component to remove some verbosity:
This means that to add animation to our old React Router code:
We just need to make this tiny addition:
And we can easily add in a new animations by simply creating a new Animator
— e.g. consider this SwitchObnoxious
:
Conclusion
React Router inevitably makes animations difficult, since it is designed to instantly route the current page position to its corresponding view-tree, with no regard for non-instantaneous animations.
But by applying a simple technique of storing our children
prop in state, we can achieve animations on page transitions, by taking control of when we unmount our old child, even when our parent wants us to do it sooner.
This is code we actually have running in production, on our Client Dashboard sign-in page. If stuff like this sounds cool to you, then you should definitely come work at Onfido! We’re hiring.
And feel free to follow me on Twitter, if you’re interested in attending talks on stuff like this in the future.