By Tara Ojo
My team recently worked on a feature to give users shortcuts to topics they follow in the FT’s App after we trialled it as an AB test and got successful results.
When we got new data from the API we wanted to have a sleek animation from the old order of topics, into the new refreshed order. We started experimenting with the feasibility of a few different animations, including one where each bubble would smoothly slide into its new position when we got its new order from the API — influenced by the Instagram story styled bubble component.
While it can be straightforward to do a whole load of animations and transitions with CSS, it took me a while to find an example of animating the reordering of list items, especially with React. Since I’ve also started to get used to the concepts of React Hooks I wanted to use them to implement this animation too.
I found this difficult to do using React hooks because my component would automatically rerender, in its new order, when it got new data. I was trying to hook into the moment before rerendering to smoothly transition from one state to another. Without the componentWillReceiveProps function call from the class components, this was hard to do.
I was under the (incorrect) assumption that there would be loads of React hooks examples out in the wild. I honestly just wanted a copypasta solution that I wouldn’t have to tweak too much 👀. I also didn’t want to bring in some huge, usually overly flexible package to reorder one small thing. I did come across a great post by Joshua Comeau (linked below). It explains how to do exactly what I needed, but with class components. With React hooks I needed to re-think some of the concepts to get it to work, but I’ve based the majority of this work on that post.
What we want to happen:
- Keep an eye out for when our element list is going to change
- When it changes we want to calculate the previous positions and the new positions of each element in the list before the DOM updates
- Also before the DOM updates with the new order of the list we want to “pause” the update and show a smooth transition of each item in the list from its old position to its new position
Let’s start with a parent component that just renders the children that is passed into it, AnimateBubbles:
Then we can use that component by rendering our items inside of it. In my case I’ve created a Bubble component that adds the styles to make each image a circle, the full code is here. The Bubble component also forwards the ref onto the DOM element. This is important as we can use the ref to find where the element is rendered in the DOM, then we can calculate its position. Another important prop is the key, this is not only needed for React when mapping over elements, but we can also use later to uniquely identify each item and match its old and new positions in the DOM.
Now that we have the foundations of our components we can start building out the logic of our AnimateBubbles component.
Keeping an eye out for React rerenders
With React hooks, we no longer have access to lifecycle methods like componentWillReceiveProps or componentDidUpdate, instead it’s all about effects. If we want to do something when a prop changes we can do the work inside of a useEffect. The useEffect hook tells React that our component needs to do something after it renders. In our case we only want to do any work if our list changes and the new order is rendered. Adding children as a dependency allows us to do that.
Measure each position in the the DOM
To calculate the position of each child in the DOM whenever the children prop changes, we can use getBoundingClientRect. I created a separate helper function to do this:
In this function we pass in children as an argument and use the forEach function on React.Children to iterate over them, getting the measurements of each item in an object that we can later store in state. This is where setting a key on each child is important as we store each box with its key as the object key so we can match up the old position with the new position later. It is also why creating a ref for each child is important, as we use that to find the element in the DOM and measure the bounding box for it. Now, when we call this function inside of the useEffect we will get the bounding box for each child updated every rerender 🎉.
The problem now is we’re only getting the new positions, but we also need the old positions so we can do the slide animation from old position to new position.
Getting the previous state/props with the usePrevious hook
One way in which we can get the old positions of the children is by keeping track of the previous state of the children. The React docs already suggest a hook for this called usePrevious. They say that it may be provided out of the box in the future since it is considered a common use case. Using usePrevious means we can do the exact same thing to measure the bounding box of the old positions as we do for the new positions.
I’ve put this into a separate useEffect as they don’t need to be done together and they both have different dependencies. These previous positions will now be recalculated every time children change.
Now we have these two important pieces of information, we can move on to the actual transition 😅
When making the actual animation I looked more into FLIP as was talked about in Joshua Comeau’s post. FLIP stands for First, Last, Invert, Play, coined by Paul Lewis as a principle for rendering more performant animations.
So in our case, we find the first position of each child. We have this stored in state in prevBoundingBoxes. Then we find the last position of each child. We also have this stored in state in boundingBoxes. The next step is to invert, which is to figure out how each child has changed and apply those transformations to each child so it looks like it is in its first position.
We can set up a new useEffect to do this with dependencies on children, boundingBoxes and prevBoundingBoxes as we’ll use all of those values in the effect. Remember when React rerenders, it instantly updates the view with the new state, but we can use requestAnimationFrame to tell the browser that we want to perform an animation. The browser will call the function you give it before the next repaint.
This we can do with first — last = inverted-value on the left values of the box. Since we also have a reference to the DOM node, we can apply the transform straight onto the node with a transition of 0 seconds so it inverts instantly.
The final step is to play the animation. To do this we wait for the child elements to be inverted, then we remove the transform and apply a smooth transition. The elements then slide into their new positions 💃🏾💃🏾💃🏾.
Remove the glitch
While I started work with useEffect I found that it was looking super glitchy, then after digging around in the React docs I found this tip:
“Unlike componentDidMount or componentDidUpdate, effects scheduled with useEffect don’t block the browser from updating the screen. This makes your app feel more responsive. The majority of effects don’t need to happen synchronously. In the uncommon cases where they do (such as measuring the layout), there is a separate useLayoutEffect Hook with an API identical to useEffect.” — React docs
Since our aim is to measure the layout of elements in the DOM, what we actually need is useLayoutEffect. As noted, useLayoutEffect has an identical API to useEffect so I could easily switch out one for the other and it made the whole animation look super smooth 😎
While this is technically pretty cool, it’s not actually live 👀. While working with our designer we decided on a simpler animation that would avoid the bubble text crossing over other bubble text during the transition. We opted for a fade out and in animation instead which meant we got the smarter aesthetic we were looking for.
- Joshua Comeau’s Animating the Unanimatable post was the foundation for this work so I recommend reading, Joshua talks a lot more about DOM rendering and the FLIP concept.
- I initially tried this using the “Magic Move” concept based on Ryan Florence’s React.js conf talk. The codesandbox for this is here, but this version is pretty complex and incomplete.