November 22, 2022
Inside Framer's Magic Motion
A guide on recreating framer's magical layout animations.
My favourite part about Framer Motion by far is its magical layout animations—slap on the layout
prop to any motion component, and watch as that component seamlessly transitions from one part of the page to the next:
<motion.div layout />
<motion.div layout />
- function
- hello
- (
- )
- {
left-paren
right-paren
left-curly
- console
- .
- log
- (
- hello, world!
- )
dot
left-paren
right-paren
- }
right-curly
When you click on "Hide types", the squares seamlessly move from one position to the next.
In this post, I want to dive deep into the techniques that allow these layout animations to happen. Specifically, we'll go over concepts like:
- Layout changes, what they are and when they occur;
- CSS-based approaches and why they don't always work;
- FLIP, the technique in use by Framer Motion;
Let's get started!
Layout Changes
A layout change happens when an element on the page changes position in a way that affects other elements on the page. For example, changing the width
or height
of an element is a layout change because any neighbouring element has to move to make room for the element's new size:
Similarly, changing the justify-content
property of an element is also a layout change because it causes that element's children to change positions:
justify-content: flex-start
On the other hand, making the same change using something like scale
is not a layout change because transforms don't affect other elements on the page at all:
Animating With CSS
So how would we animate layout changes? One way is by animating the property directly using something like CSS transitions:
.square {
transition: width 0.2s ease-out;
}
.square {
transition: width 0.2s ease-out;
}
Now when the square changes width, it'll seamlessly animate between its sizes:
To be honest, in many cases, we can end the post here!
But there are two main downsides with CSS animations that we should be aware of:
- You can't animate everything. For example, you can't animate a change in
justify-content
becausejustify-content
is not an animatable property. - There may be a performance trade-off. CSS animations that involve layout changes are generally more expensive than transform-based animations, so you might find that your animations are not as smooth on lower-end devices.
Let's talk about the performance problem for a second.
Performance
Don't pre-optimize! If you're not noticing any performance issues on lower-end devices and CSS transitions work for you, then don't worry about it! Only optimize when you need to.
CSS animations that involve layout changes are generally more expensive than other CSS animations because it affects other elements around it. This is because the browser has to recalculate the layout of the page in every frame of the animation—for a 60 FPS animation, that means 60 times every second!
Recall the animation from the previous section. Notice that the grey boxes also look like they're animating, even though we only transition the blue box:
This happens because the browser recalculates the position of the grey boxes every time the blue box changes size.
On the other hand, the browser can animate CSS properties like transform
much faster because they don't affect layout:
Notice that as the blue box grows, the grey boxes stay put!
Hey, wait a second. If transform
is much cheaper to animate, can we somehow animate layout changes using transform
instead?
Introducing FLIP
Yes, you can!
FLIP, which stands for First, Last, Inverse, Play, is a technique that lets you animate "slow" layout changes using "fast" CSS properties like transform
. FLIP even lets you animate "un-animatable" properties like justify-content
too! Framer Motion uses FLIP under the hood to implement its layout animations.
As its name suggests, FLIP is a four-step technique that works by inverting any layout changes done by the browser. Let's figure out how it works by animating this change in justify-content
from flex-start
to flex-end
:
justify-content: flex-start
First
In the First step, we measure the position of the element we're animating before any layout changes have happened:
One way to do this is to use the .getBoundingClientRect()
method of the HTML element:
const Motion = (props) => {
const ref = React.useRef();
React.useLayoutEffect(() => {
const { x, y } = ref.current.getBoundingClientRect();
}, []);
return <div ref={ref} {...props} />;
};
const Motion = (props) => {
const ref = React.useRef();
React.useLayoutEffect(() => {
const { x, y } = ref.current.getBoundingClientRect();
}, []);
return <div ref={ref} {...props} />;
};
Why useLayoutEffect?
We're using useLayoutEffect
instead of useEffect
because we want our code to run before the browser "re-renders" the screen. This way we don't get an awkward flash when we move the element around later on.
You can read more about useLayoutEffect
in the React docs.
Last
In the Last step, we measure the position of the element after the layout changes has happened:
justify-content: flex-start
To get this working in code, we'll first assume that a layout change means the component just re-rendered. So let's start by removing the dependency array from the useEffect
hook to make the hook run every render.
Try triggering the layout change a few times and check the console to see what x
and y
values show up:
Pop quiz time!
After the layout change, is box
in the snippet above referring to the
initial position or the final position of the square?
If you answered , you'd be right!
This is because the useEffect
hook runs after the component renders. So when we call getBoundingClientRect()
getBoundingClientRect()
in the useEffect
hook, we're actually getting the position of the square the layout change.
So how do you get the position?
One way is to create a ref (using useRef
) and store the previous value there every time you measure the box:
import React from 'react' export default function Motion() { const squareRef = React.useRef(); const initialPositionRef = React.useRef(); React.useLayoutEffect(() => { const box = squareRef.current?.getBoundingClientRect(); if (box) { // final position console.log(box.x, box.y) // initial position console.log( initialPositionRef.current?.x, initialPositionRef.current?.y ); initialPositionRef.current = box; } }); return <div id="motion" ref={squareRef} />; }
Inverse
In the inverse phase, we modify the position of the square so that it looks like it didn't move at all. To do this, we compare the two measurements we made and calculate a transform that we then apply to the square:
Here's a React implementation of the technique:
import React from 'react' export default function Motion() { const squareRef = React.useRef(); const initialPositionRef = React.useRef(); React.useLayoutEffect(() => { const box = squareRef.current?.getBoundingClientRect(); if (moved(initialPositionRef.current, box)) { // get the difference in position const deltaX = initialPositionRef.current.x - box.x; const deltaY = initialPositionRef.current.y - box.y; console.log(deltaX, deltaY); // apply the transform to the box squareRef.current.style.transform = `translate(${deltaX}px, ${deltaY}px)`; } initialPositionRef.current = box; }); return <div id="motion" ref={squareRef} />; } const moved = (initialBox, finalBox) => { // we just mounted, so we don't have complete data yet if (!initialBox || !finalBox) return false; const xMoved = initialBox.x !== finalBox.x; const yMoved = initialBox.y !== finalBox.y; return xMoved || yMoved; }
Notice that if you press toggle, nothing happens! This is because the square was transformed to look like it didn't move an inch.
Play
So far, we have a square that has a transform applied to it to make it look like it didn't move after toggle is pressed.
In the final step of FLIP, the Play step, we animate this transform down to zero to make the square animate to its final position.
There are various ways that you can implement this animation; I personally opted to use the animate
function from Popmotion:
import React from 'react' import { animate } from 'popmotion' export default function Motion() { const squareRef = React.useRef(); const initialPositionRef = React.useRef(); React.useLayoutEffect(() => { const box = squareRef.current?.getBoundingClientRect(); if (moved(initialPositionRef.current, box)) { // get the difference in position const deltaX = initialPositionRef.current.x - box.x; const deltaY = initialPositionRef.current.y - box.y; // inverse the change using a transform squareRef.current.style.transform = `translate(${deltaX}px, ${deltaY}px)`; // animate back to the final position animate({ from: 1, to: 0, duration: 2000, onUpdate: progress => { squareRef.current.style.transform = `translate(${deltaX * progress}px, ${deltaY * progress}px)`; } }) } initialPositionRef.current = box; }); return <div id="motion" ref={squareRef} />; } const moved = (initialBox, finalBox) => { // we just mounted, so we don't have complete data yet if (!initialBox || !finalBox) return false; const xMoved = initialBox.x !== finalBox.x; const yMoved = initialBox.y !== finalBox.y; return xMoved || yMoved; }
Putting Everything Together
By doing all of the steps together, we get...
- First
- Last
- Inverse
- Play
Voila! Magical layout animations.
Animating Size
So far we've only used FLIP to animate a change in position. Can we do the same thing but for size? Let's try to replicate the following animation where the square stretches to fill the whole container:
width: 120px
We won't mix changes in position and size together for now; we'll get to that in a bit.
Measuring Size Changes
We'll start off by measuring the size of the square before and after the layout change. Thankfully, the .getBoundingClientRect()
method we used to measure the square also happens to return the width
and height
of the element:
const { width, height } = squareRef.current.getBoundingClientRect();
const { width, height } = squareRef.current.getBoundingClientRect();
120px
- First
- Last
Inverting Size Changes
To invert the size change, we'll divide the final size with the initial size:
const deltaWidth = box.width / initialBoxRef.current.width;
const deltaWidth = box.width / initialBoxRef.current.width;
This gives us a number that we can pass to scale
:
squareRef.current.style.transform = `scaleX(${deltaWidth})`;
squareRef.current.style.transform = `scaleX(${deltaWidth})`;
120px
0px
And instead of animating the scale to zero like we did with position, we'll animate the scale to one (if we animate to zero instead, the element will disappear altogether):
animate({
from: deltaWidth,
to: 1,
// ...
});
animate({
from: deltaWidth,
to: 1,
// ...
});
Consolidating Size with Position
Cool! So far we're able to use FLIP to animate changes in position and size. What happens when we try to animate both size and position?
Hmm, that looks a little off. What's going on here? If we pause the animation just before the play step, we can see that something went wrong in the inverse step - the square isn't quite lining up with its original position:
Fixing Transform Origins
Let's try to figure this out.
When we combine changes in position and size, we're performing two separate transformations in the inverse step — a translation and a scale. If we take a look at those transformations individually, we can see how the square ended up where it did:
Our algorithm first lines up the top left point of the final position with the top left point of the original position, and then it scales it down to the initial size.
The scale transform seems to be the culprit here - it's scaling from the center of the square, causing the square to end up in the wrong location. Now if we change the transform origin to the top left instead so that it lines up with the translation...
squareRef.current.style.transformOrigin = "top left";
squareRef.current.style.transformOrigin = "top left";
Would you look at that; it works!
What if Transform Origins Change?
Of course, the big caveat with this solution is that we've hard coded in the transform origin value. What if the user wants a different transform origin? The layout animation should still work in this case.
The trick, it turns out, is to make sure the inverse step compares the distance between the transform origins of the two squares. To put it another way, the bug is happening because of a discrepancy between the measured distance and the transform origins: getBoundingClientRect()
returns the top left point of the element whereas the transform origin is at the center of the element by default.
The distance between the top left point and the distance between the centers are only equivalent when the two squares are the same size:
I'm only comparing the horizontal distance here for simplicity - the same concept applies if we take into account the vertical distance too.
When the final square is larger, the distance between the centers is larger than the distance between the top left points. Similarly, when the final square is smaller, the distance between the centers is smaller than the distance between the top left points.
With this insight, we can also solve the bug by using the distance between the centers instead of the top left points:
Correcting Child Distortions
Great! So far, we're able to make a layout animation that can seamlessly transition changes in size and position. Now let's add another test - what happens if our element has child elements?
Oh no! The text appears to be changing size. How do we fix this?
The culprit here is once again the inverse scale transform. When we're inverting to a smaller square, the text ends up smaller because the square is scaled down. Similarly, when we're inverting to a larger square, the text ends up larger because the square is scaled up.
This leads us to our problem:
Inverse Scale Formula
One way is to apply another transform on the child element that "cancels out" the parent's transform. One transform we can do is:
childScale = 1 / parentScale
The idea is if the parent gets twice as large, then the child needs to halve its size for it to stay the same size. Try moving the slider below and notice how the text stays the same size regardless of the size of the square:
Great! Now how would we integrate this with our layout animations?
First Attempt
The first thing that I tried was to calculate the inverse scale once, just before the parent is about to animate, and then running a separate animation on the child:
/* this runs in the child when the parent is about to animate */
const inverseTransform = {
scaleX: 1 / parentTransform.scaleX,
scaleY: 1 / parentTransform.scaleY,
};
play({
from: inverseTransform,
to: { scaleX: 1, scaleY: 1 },
});
/* this runs in the child when the parent is about to animate */
const inverseTransform = {
scaleX: 1 / parentTransform.scaleX,
scaleY: 1 / parentTransform.scaleY,
};
play({
from: inverseTransform,
to: { scaleX: 1, scaleY: 1 },
});
For example, if the parent is animating from scaleX: 2
to scaleX: 1
, then the child will be animating from scaleX: 1 / 2
to scaleX: 1
using the same timing. My thinking was that as long as the timing of the scale correction is the same as the parent animation, this approach should work.
Except I was wrong, because this is what the approach produces:
Er, it's doing something, but the text is still clearly changing size throughout the animation.
The Correct Scale Timing
The problem here lies in this assumption:
As long as the timing of the scale correction is the same as the parent animation, this approach should work.
In reality, the "correct" inverse scale does not change in the same manner as the parent animation. Instead it kinda does its own thing:
In the example above, the blue line shows the scale of the parent, while the yellow line shows the scale of the child. Notice that the blue line is a straight line whereas the yellow line is a bit of a curve. This tells us that the timing of the inverse scale is not the same as the parent scale!
To fix this, we can either:
- Calculate the correct timing ahead of time, or;
- Calculate the inverse scale every time the parent scale changes.
(2) happens to be drastically simpler than (1), and also allows us to handle all sorts of different timings on the parent. This also happens to be the approach that Framer Motion uses.
animate({
from: inverseTransform,
to: {
x: 0,
y: 0,
scaleX: 1,
scaleY: 1,
},
onUpdate: ({ x, y, scaleX, scaleY }) => {
parentRef.style.transform = `...`;
const inverseScaleX = 1 / scaleX;
const inverseScaleY = 1 / scaleY;
childRef.style.transform = `scaleX(${inverseScaleX}) scaleY(${inverseScaleY}) ...`;
},
});
animate({
from: inverseTransform,
to: {
x: 0,
y: 0,
scaleX: 1,
scaleY: 1,
},
onUpdate: ({ x, y, scaleX, scaleY }) => {
parentRef.style.transform = `...`;
const inverseScaleX = 1 / scaleX;
const inverseScaleY = 1 / scaleY;
childRef.style.transform = `scaleX(${inverseScaleX}) scaleY(${inverseScaleY}) ...`;
},
});
import React from 'react' import { animate } from 'popmotion' export default function Motion({ toggled, corrected, children }) { const squareRef = React.useRef(); const childRef = React.useRef(); const initialPositionRef = React.useRef(); React.useLayoutEffect(() => { const box = squareRef.current?.getBoundingClientRect(); if (changed(initialPositionRef.current, box)) { const transform = invert(squareRef.current, box, initialPositionRef.current) animate({ from: transform, to: { x: 0, y: 0, scaleX: 1, scaleY: 1 }, duration: 1000, onUpdate: ({ x, y, scaleX, scaleY }) => { squareRef.current.style.transform = `translate(${x}px, ${y}px) scaleX(${scaleX}) scaleY(${scaleY})`; if (corrected) { childRef.current.style.transform = `scaleX(${1 / scaleX}) scaleY(${1 / scaleY})`; } } }) } initialPositionRef.current = box; }); return ( <div id="motion" ref={squareRef} style={{ width: toggled && '100%', aspectRatio: 'initial', height: 120 }} > <div ref={childRef}>{children}</div> </div> ); } const changed = (initialBox, finalBox) => { // we just mounted, so we don't have complete data yet if (!initialBox || !finalBox) return false; // deep compare the two boxes return JSON.stringify(initialBox) !== JSON.stringify(finalBox); } const invert = (el, from, to) => { const { x: fromX, y: fromY, width: fromWidth, height: fromHeight } = from; const { x, y, width, height } = to; const transform = { x: x - fromX - (fromWidth - width) / 2, y: y - fromY - (fromHeight - height) / 2, scaleX: width / fromWidth, scaleY: height / fromHeight, }; el.style.transform = `translate(${transform.x}px, ${transform.y}px) scaleX(${transform.scaleX}) scaleY(${transform.scaleY})`; return transform; }
That's Not How it Really Works, Right?
The way that I made the scale correction work in this case is by wrapping the child element in a <div>
and applying the scale correction to the <div>
. This implies a few things:
- There are two elements in the DOM for one Motion component, which may be problematic from a UX perspective;
- All child components are scale corrected — there's no way for one child to be corrected and another not;
- There may be issues if the child component is also animating — I haven't tested this, but I assume the scale correction will cause issues because we're distorting the child's coordinate space.
Framer Motion does things a bit differently; you have to opt in to scale correction by making your child component a layout component:
<motion.article layout>
<motion.h1 layout>Hello!</motion.h1> <-- is scale corrected
<p>World!</p> <-- is not scale corrected
</motion.article>
<motion.article layout>
<motion.h1 layout>Hello!</motion.h1> <-- is scale corrected
<p>World!</p> <-- is not scale corrected
</motion.article>
This API implies that the child component needs to be able to "hook in" to the parent's animation, which makes the implementation a tad more complex.
I opted to not implement things this way because I didn't want to take away from the core scale correction concept. If you're interested though, this part of the Framer Motion source code seems to be a good place to start — it looks like they maintain their own DOM-like tree of motion components using something called "projection nodes".
Summary
If you made it all the way here, thank you! Let's recap what we've learned.
Ultimately, we wanted to figure out how to animate layout changes, that is, changes in an element that affect the position of itself and all surrounding elements.
We started off using CSS but then realized it fell short in a couple of ways:
- You can't use CSS to animate un-animatable properties like
justify-content
; - Animating layout properties can be slow in lower-end devices;
In the process, we found out that animations using transform
are fast and easy on the browser, so we turned our attention to FLIP - a technique used by Framer Motion that exploits this property.
While implementing FLIP with position changes was pretty straightforward, the same can't be said for changes in size. When we start considering changes in size, we find we have to start worrying about:
- How a change in size affects the distance the element traveled;
- Correcting distortions in child elements caused by transforms in the parent element;
Once we figured out both of these problems, we ended up with a pretty solid implementation of automatic layout animation!
That's all for today; thanks for reading!
Addendum
Matt Perry, the mastermind behind Framer Motion, graciously offered to expand a bit on how Framer Motion works in his Now in Motion newsletter:
A straightforward FLIP implementation would be a “view” transition - the difference between how the viewport looks before and after a change.
Whereas Framer Motion is attempting to do “layout” transitions. As this sandbox demonstrates, when a page scroll is thrown into play, we don’t want to animate this vertical change. It doesn’t look good when view transitions animate page scroll.
A further key difference between FLIP is that rather than animating this initial “inverted” delta down to 0, while we do this, once every frame we first convert this delta to a bounding box where we want the animating element to appear on screen every frame. We call this a “projection target”.
This is how we perform scale correction and shared element transitions. By getting this projection target as a box, once every frame we can apply all the transforms currently applied to this box by its ancestors. From there, we can calculate the transform actually required to get the element from its transformed and scrolled position on screen, to the projection target.
Performing shared element transitions becomes a matter of calculating a transform that gets a second element into this same projection target.
Really appreciative of Matt here to chime in and provide more context!