Build a Simple FLIP Animation in React
7 min readThe 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 <div4 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:
- Transitions for
width
andheight
won’t happen on the GPU, which end up causing layout reflow resulting in jank. - 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 = bounds7 }, [size])8 return (9 <div10 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 = bounds8}, [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 } = transform11 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 = bounds18}, [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}1314function 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.current15 )16 if (lastBounds.current) {17 const invertedTransform = getInvertedTransform(18 animation.current19 ? applyTransformToBounds(lastBounds.current, lastTransform.current)20 : lastBounds.current,21 bounds22 )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 } = transform32 const translate = `translate(${x}px, ${y}px)`33 const scale = `scale(${scaleX}, ${scaleY})`34 ref.current.style.transform = `${translate} ${scale}`35 lastTransform.current = transform36 },37 onComplete: () => {38 animation.current = null39 },40 })41 }42 lastBounds.current = bounds43 }, [size])44 return (45 <div46 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
Animating Layouts with the FLIP Technique
- animation
- react