Animating the Unanimatable.

Smooth reordering transitions in React.js

Joshua Comeau
Developers Writing

--

Between CSS3 transitions, @keyframe animations, and wonderful new technologies like the upcoming Web Animations API, it’s never been easier to make smooth, jank-free animations.

There’s still one thing that none of these technologies can handle out of the box, though; animated list reordering.

List reordering. Live Example

Identifying The Problem

Let’s say you have something like:

We have a parent ArticleList component which takes a list of articles as its props. It maps through them, in order, and renders them.

If that list order changes (examples: the user toggles a setting that changes the sorting, an item gets upvoted and changes position, new data comes in from the server…), React reconciles the two states, and destroys / removes / appends / creates nodes as needed.

The DOM is dumb. If an item is removed from its original location and re-inserted at a new location 200px down, it has no awareness about what that update means for the element’s on-screen position.

Because the element’s CSS properties haven’t changed, there is no way to use CSS transitions to animate this change.

Edit note: Originally, this article stated that DOM nodes couldn’t be moved, only created/destroyed. It turns out I was wrong; operations like .appendChild() appear to preserve the element. Luckily, my misunderstanding doesn’t affect the end result, in our case.

How can we get the browser to behave as though these elements have moved? The solution to this problem will take us on a ride through low-level DOM operations, React lifecycle methods, and hardware-accelerated CSS practices. There will even be some basic maths!

The solution

TL:DR — I made a React module that does this.
Source | Demo

To solve this problem, there are a few pieces of info we need, and we need them at a very specific moment in time. Let’s forego the complexity in acquiring them, for now, and operate on the assumptions that:

  • We know that React just re-rendered, and the DOM nodes have been re-arranged.
  • The browser hasn’t painted yet. Even though the DOM nodes are in their new positions, the on-screen elements haven’t been updated yet.
  • We know where the elements are, on the screen.
  • We know where the elements are about to be re-painted.

Here’s what our situation might be: We have a list of 3 items, and they were just reversed. We know their original position (left side), and we know where they’re moving to (right side).

Kindly ignore my lack of artistic ability

Order of Operations

A quick aside: It may surprise you to learn that there exists a moment in time where we can tell where an item will be before it has been painted to the screen.

When you think about it, it makes sense; how can the browser paint new pixels to the screen before it knows exactly where to paint them?

Thankfully, this is not a black box; The browser updates in distinct steps, and it is possible to execute logic between calculating the layout and painting to the screen.

But how do we access the calculated layout?

DOMRects to the rescue!

Javascript has an incredibly helpful native method, getBoundingClientRect(). It gives us the size and position of any given element, relative to the viewport. Here’s what it might give us if we called it on that top blue rectangle, before the new layout is calculated:

blueItem.getBoundingClientRect()
>> {
>> top: 0,
>> bottom: 600,
>> left: 0,
>> right: 500,
>> height: 60,
>> width: 400
>> }

And, after the new layout is calculated:

blueItem.getBoundingClientRect()
>> {
>> top: 136,
>> bottom: 464,
>> left: 0,
>> right: 500,
>> height: 60,
>> width: 400
>> }

getBoundingClientRect is smart enough to work out the new layout position of an element, taking into account its height, margin, and any other variables that will affect where it is in the viewport.

Armed with these two pieces of data, we can work out the change in the element’s position; its delta.

Δy = finalTop - initialTop = 132 - 0 = 132

So, we know that the element has moved down 132px. Similarly, we know that the middle item hasn’t moved at all (Δy = 0px), and the last item has moved up by 132px (Δy = -132px).

The problem is, while we know all these facts, the DOM is about to update; In a fraction of a second, those boxes will instantly be in their new position!

This is where the next tool in our arsenal comes in: requestAnimationFrame.

This is a method on the window object that tells the browser “Hey, before you paint any changes to the screen, can you run this bit of code first?”. It’s a way to quickly make any adjustments needed before the elements are updated.

What if, before the browser paints, we apply the inverse of the change? Imagine this CSS:

.blue-item {
top: -132px
}
.purple-item {
top: 0;
}
.fuscia-item {
top: 132px;
}

The browser would paint this update, but the paint wouldn’t change anything; The DOM nodes have changed places, but we’ve offset that change with CSS.

Note: Don’t yell at me yet, experienced front-end devs! We’ll discover a more performant way below to do this re-arranging. I’m just using the top property right now because it’s easier to understand.

This is tricky business, so let’s do a high-level overview of what just happened:

  1. React renders our initial state, with the blue item on top. We use getBoundingClientRect to figure out where the items are positioned.
  2. React receives new props: the items have been reversed! Now the blue item is on the bottom.
  3. We use getBoundingClientRect to figure out where the items are now, and calculate the change in positions.
  4. We use requestAnimationFrame to tell the DOM to apply some CSS that undoes this new change; If the element’s new position is 100px lower, we apply CSS to make it 100px higher.

It’s Animation Time

Ok, so we’ve definitely accomplished something here; we’ve made it so that DOM changes are completely invisible to the user. This might be a neat party trick, but it’s probably still not clear how this helps us.

The thing is, we’ve made it so that we’re in a situation where regular CSS transitions can work again. To animate these elements to their new position, we can just add a transition and undo the artificial position changes.

Continuing with our example above: Our blue item is actually the final item, but it appears to be the first one. Its CSS looks like this:

.blue-item {
top: -132px;
}

Now, let’s update the CSS so it looks like this:

.blue-item {
transition: top 500ms;
top: 0;
}

The blue item will now slide down, over half a second, from the top position to the bottom position. Huzzah! We’ve animated something.

This technique was popularized by Google’s Paul Lewis, and he calls it the FLIP technique. FLIP is an acronym for First, Last, Inverse, Play.

No, not that kind of flip.
  • Calculate the First position.
  • Calculate the Last position.
  • Invert the positions
  • Play the animation

Our version is a little different, but it’s the same principle.

A Brief Foray into the DOM

While learning about this technique and writing my module, I learned quite a bit about DOM rendering. While most of what I learned is out of the scope of this article, there’s one tidbit we should take a quick look at: the difference between painting and compositing, and its effect on selecting hardware-accelerated CSS properties.

Originally, browsers did everything with the CPU. In recent years, some very smart people figured out that certain tasks could be delegated to the GPU for massive gains in performance; specifically, when the “texture” of a static piece of content doesn’t change.

The primary objective was to speed up scrolling; when you scroll down a page, none of the elements are changing, they’re just sliding up. The browser people were kind enough to also allow certain css properties to work the same way.

By using the transform suite of CSS properties — translate, scale, rotate, skew, etc — and opacity, we aren’t changing the texture of an element. And if the texture doesn’t change, it doesn’t have to be re-painted on every frame; it can be composited around by the GPU. This is how to achieve 60+fps animations.

Want proof that using this property just sends the element as a texture to the GPU? Try applying a non-uniform scale (eg. transform: scale(1, 2) ) to an element with some text in it.

If you’d like to learn more about the browser’s rendering process (and you should! It’s as fascinating as it is practical), I’ve included some links below.

The missing piece: React

How does React fit into all this? Happily, it turns out React works brilliantly with this technique.

There are two important things that each child needs for this to function:

  1. Every child needs a unique ‘key’ property. This is what we’ll use to tell them apart.
  2. Every child needs a ref, so that we’ll be able to look up the DOM node and calculate its bounding box.

Note: The code snippets below are greatly simplified, and should not be used as-is. For a more complete implementation, see the React Flip Move source.

Getting the First position

Whenever the component receives new props, we need to check if an animation is necessary. The earliest opportunity to do this is in componentWillReceiveProps lifecycle method.

At the end of this lifecycle method, our state will be full of DOMRect objects, outlining exactly where every child is on the page.

Getting the Last position

The next task is figuring out where things are going to be.

The very important distinction to make here is that React’s render method doesn’t immediately paint to the screen. I’m a little fuzzy on the lower-level details, but the process looks a little something like this:

  • render returns a representation of what it would like the DOM to be,
  • React reconciles this representation with the actual state of the DOM, and applies the differences,
  • The browser notices that something has changed, and calculates the new layout,
  • React’s componentDidUpdate lifecycle method fires,
  • The browser paints the changes to the screen.

The beautiful thing about this process is we have the opportunity to hook into the DOM’s state after its layout is calculated, but before the screen has been updated.

Here’s what that looks like:

Inverting

We now know both the first and last position, and there isn’t a millisecond to spare! The DOM is about to update!

We’ll use requestAnimationFrame to ensure our changes make it in before that frame.

Let’s continue writing the componentDidUpdate method:

You’ll notice we’re using the more-performant transform: translate property here, instead of messing with top/bottom. The effect is the same, but the animation is much smoother, for reasons described in our brief DOM foray.

At this point, after this method runs, our DOM nodes will have been re-arranged, but their position on the screen will have remained static. Cool! There’s only one step left…

Playing

Hah! We have done it; we have animated the unanimatable.

Extra Credit

While this may all seem like an awful lot of trouble to achieve something relatively simple, there are benefits to having such granular control over the process:

  • We can very easily implement onStart/onFinish callbacks
  • We can do neat things like incrementally offsetting the duration of the transition, so that each animated element takes a little bit longer to finish (this makes animations feel more organic)
  • The full power of Javascript is made available to us; our imagination is the only limit.

Download the Module

I created a React component that does all this stuff for you. Using it is as simple as:

When the articles prop changes, FlipMove will automagically transition its children to their new positions :)

It can be customized with a bunch of additional fields:

Play with it now:

Acknowledgements

  • Ryan Florence created a wonderful module, Magic Move, which solves the same problem, albeit in a totally different way.
  • Paul Lewis coined the term FLIP, and the ideas used here come from his fantastic blog post, FLIP your Animations.
  • Sacha Greif and Tom Coleman’s book, Discover Meteor, contains a chapter on animations, and they tackle this problem in a very similar way.

--

--