souporserious

Build a Simple FLIP Animation in React

7 min read

The FLIP technique allows for declarative and performant animations. In this article, we will look at a simple way to implement this method using React.

When building user interfaces, we should be able to do so declaratively, allowing us to easily express what we want, rather than how we get there. CSS is declarative by nature, but we can’t performantly animate layout without causing reflow and repaints, which in turn causes a laggy (also known as janky) experience. While this should be true for layout animations, they are inherently tricky and require some magic to make them performant.

This is where FLIP (First Last Invert Play) animations come to the rescue! Coined by Paul Lewis, it’s a simple formula that allows us to performantly move boxes around using transforms, avoiding reflow and repaints.

Getting started

To get started, we will define a simple Box component that takes a size prop that can be toggled between two states when clicked:

Adding continuity

Currently we can move the box back and forth and make it larger or smaller, but this all happens immediately. Let’s introduce some continuity into our design and allow the user to see how the box transitions between each state:

1function Box({ size }) {
2 return (
3 <div
4 style={{
5 width: size,
6 height: size,
7 background: 'hotpink',
8 transition: 'all 280ms cubic-bezier(.12,.66,.5,1)',
9 }}
10 />
11 )
12}

In a perfect world, we would be able to stop here and add a transition, but we have a couple of issues though:

  1. Transitions for width and height won’t happen on the GPU, which end up causing layout reflow resulting in jank.
  2. We can’t animate properties like justifyContent used in our parent container resulting in the box still jumping immediately to its next state.

Enter FLIP animations.

First, Last, Invert, Play

To perform a FLIP animation, we start by taking snapshots of the start and end positions (the First and Last portions of FLIP). We can do this in React by using a ref and the useLayoutEffect hook to store the element’s measurements every time the size prop changes since we know the layout has changed at this point:

Note we set transform-origin as well so our calculations are always from the top-left.

1function Box({ size }) {
2 const ref = React.useRef(null)
3 const lastBounds = React.useRef(null)
4 React.useLayoutEffect(() => {
5 const bounds = ref.current.getBoundingClientRect()
6 lastBounds.current = bounds
7 }, [size])
8 return (
9 <div
10 ref={ref}
11 style={{
12 width: size,
13 height: size,
14 background: 'hotpink',
15 transformOrigin: 'top left',
16 }}
17 />
18 )
19}

Now that we have the First and Last snapshots we can move onto calculating the Invert. We’ll start by adding a small utility to help calculate the difference between our two bounding rectangles:

1function getInvertedTransform(startBounds, endBounds) {
2 return {
3 x: startBounds.x - endBounds.x,
4 y: startBounds.y - endBounds.y,
5 scaleX: startBounds.width / endBounds.width,
6 scaleY: startBounds.height / endBounds.height,
7 }
8}

Now we can use this to calculate the inverse of where our box was previously:

1React.useLayoutEffect(() => {
2 const bounds = ref.current.getBoundingClientRect()
3 if (lastBounds.current) {
4 const invertedTransform = getInvertedTransform(lastBounds.current, bounds)
5 // now we have enough information to animate to the next position!
6 }
7 lastBounds.current = bounds
8}, [size])

For our animation, we’ll be using Popmotion to power our FLIP animation:

1React.useLayoutEffect(() => {
2 const bounds = ref.current.getBoundingClientRect()
3 if (lastBounds.current) {
4 const invertedTransform = getInvertedTransform(lastBounds.current, bounds)
5 animate({
6 from: invertedTransform,
7 to: { x: 0, y: 0, scaleX: 1, scaleY: 1 },
8 duration: 800,
9 onUpdate: (transform) => {
10 const { x, y, scaleX, scaleY } = transform
11 const translate = `translate(${x}px, ${y}px)`
12 const scale = `scale(${scaleX}, ${scaleY})`
13 ref.current.style.transform = `${translate} ${scale}`
14 },
15 })
16 }
17 lastBounds.current = bounds
18}, [size])

At this point, the box is rendered in its Last position. We then apply the Invert value to pull it back to the First state, mimicking the initial state’s appearance. When running the animate function, we then Play the transition and animate the box back to its rendered area.

Whew, that’s a lot! Try changing the duration and open up the console to see how the animation is applied. Notice we don’t animate width or height and instead animate the performant scale and translate properties:

Interruptible animations

We’re looking pretty good so far! Our box animates performantly back and forth, but if you happen to click too fast before the animation is complete, you’ll notice that the box randomly scales and jumps around before animating back to the desired state.

Since we aren’t working with a traditional layout, we need to keep track of our current transforms we’ve applied to calculate from the current position correctly.

We’ll introduce two more utility functions to help with this:

1function removeTransformFromBounds(bounds, transform) {
2 return {
3 width: bounds.width / transform.scaleX,
4 height: bounds.height / transform.scaleY,
5 top: bounds.top - transform.y,
6 right: bounds.right - transform.x,
7 bottom: bounds.bottom - transform.y,
8 left: bounds.left - transform.x,
9 x: bounds.x - transform.x,
10 y: bounds.y - transform.y,
11 }
12}
13
14function applyTransformToBounds(bounds, transform) {
15 return {
16 width: bounds.width * transform.scaleX,
17 height: bounds.height * transform.scaleY,
18 top: bounds.top + transform.y,
19 right: bounds.right + transform.x,
20 bottom: bounds.bottom + transform.y,
21 left: bounds.left + transform.x,
22 x: bounds.x + transform.x,
23 y: bounds.y + transform.y,
24 }
25}

These functions respectively remove or add transformations to our bounding rectangle. We still need a few other pieces of information, though, to calculate the position and size correctly.

We’ll store the current animation that is running as well as the last transform that was applied. Now when calculating our bounds, we will remove the current transform that is being applied in order to get an accurate measurement for our final position. We also check before calculating the inverse if there is an animation currently running, and if so, apply that transformation:

1function Box({ size }) {
2 const ref = React.useRef(null)
3 const animation = React.useRef(null)
4 const lastBounds = React.useRef(null)
5 const lastTransform = React.useRef({
6 x: 0,
7 y: 0,
8 scaleX: 1,
9 scaleY: 1,
10 })
11 React.useLayoutEffect(() => {
12 const bounds = removeTransformFromBounds(
13 ref.current.getBoundingClientRect(),
14 lastTransform.current
15 )
16 if (lastBounds.current) {
17 const invertedTransform = getInvertedTransform(
18 animation.current
19 ? applyTransformToBounds(lastBounds.current, lastTransform.current)
20 : lastBounds.current,
21 bounds
22 )
23 if (animation.current) {
24 animation.current.stop()
25 }
26 animation.current = animate({
27 from: invertedTransform,
28 to: { x: 0, y: 0, scaleX: 1, scaleY: 1 },
29 duration: 800,
30 onUpdate: (transform) => {
31 const { x, y, scaleX, scaleY } = transform
32 const translate = `translate(${x}px, ${y}px)`
33 const scale = `scale(${scaleX}, ${scaleY})`
34 ref.current.style.transform = `${translate} ${scale}`
35 lastTransform.current = transform
36 },
37 onComplete: () => {
38 animation.current = null
39 },
40 })
41 }
42 lastBounds.current = bounds
43 }, [size])
44 return (
45 <div
46 ref={ref}
47 style={{
48 width: size,
49 height: size,
50 background: 'hotpink',
51 transformOrigin: 'top left',
52 }}
53 />
54 )
55}

Putting it all together, we now have interruptible, performant animations between both states of our box using any layout method we want. Try changing how the Flex alignment is applied or maybe use CSS Grid and see that the box will always animate correctly to its next state!

Summary

This was a simplified look at how FLIP animations work and the nuances they introduce, like the complexity of merely interrupting the currently running animation. We also assume justifyContent changes in the parent when the size prop changes, in a real-world situation, we would need an event system to notify us of updates like this. Not to mention if we were to have included text in our UI, we would need to apply scale correction since the box is distorted during the transition. Ideally, a library like Framer Motion will handle these differences for you. Hopefully, one day, the platform can ship these optimizations so we can effortlessly build performant UI and focus on more critical tasks.

Resources

Flip Your Animations

Framer Magic Move

Animating Layouts with the FLIP Technique

React Morph

React FLIP Toolkit

Updated:
  • animation
  • react
Previous post
Stack Components with CSS Grid
Next post
The Key to Calling React Hooks Conditionally
© 2022 Travis Arnold