The Inner Workings of Magic Motion
A behind-the-scenes look at our new smart animation tool that enables designers to create their own custom animations and transitions.
Magic Motion is a powerful new feature in Framer that allows designers to draw a line between any two screens and create a prototype that will smoothly animate between them.
It’s built on the new auto-animate and shared layout features in Framer Motion 2. As a developer, handoff is no longer a pit-of-the-stomach experience, a hasty “you can’t do that on the web!” Implementing a Magic Motion prototype as a production-ready, URL-driven animation between completely different views is just a few lines of React markup.
The problem
CSS offers a number of different layout systems that can all interoperate with each other. A flexbox can be placed within an absolutely positioned grid that sits within a static float: right
. With all these possibilities, calculating how a webpage should look is expensive. At 60fps, a browser has just 16.6 milliseconds to update the screen before the next frame. It’s unlikely that a browser will be able to update a layout within that frame budget, so there’s no real API that lets you try.
Take this switch, which is laid out using a simple flexbox. Even with transition: all
, changing justify-content
has an instant effect.
.switch { justify-content: align-start; transition: all;} .switch.on { justify-content: align-end;};
FLIP
The technique at the core of performant layout transitions in the browser was first described by Paul Lewis. It’s called FLIP, which stands for First, Last, Invert, Play.
It works like this:
- Measure the first layout
- Update the CSS and measure the last layout
- Apply the inverted delta as a
transform
to make the last layout look like the first - Play the animation
So we do the expensive thing (layout) at the start of the animation, where we have a window of time where the user won’t notice heavy work. Then we do the cheap thing (animating transform
) once per frame.
For very simple use-cases, this is enough to smoothly animate layout.
But there’s a drawback to FLIP that can instantly wreck the illusion: scale distortion.
By replacing the animation of width
and height
with scaleX
and scaleY
, every style that was bound to that width and height is visibly broken. This includes box-shadow
, border-radius
, and the size and styles of any children too. Notice the distortions in this example as it switches between the two visual states.
Magic Motion’s key innovation is the ability to correct all of this visual distortion, throughout an infinitely deep tree.
This is scale correction. We apply it to CSS properties that can be corrected without triggering layout, like box-shadow
and border-radius
.
It’s applied throughout a tree on any component that a user has set to automatically animate.
<motion.div animate />
Or has included in a shared element transition.
<motion.div layoutId="header" />
In the future, we may bring scale correction to all motion
components.
Correcting CSS styles
Correcting the appearance of border-radius
and box-shadow
is a three-step process. First, if we’re animating between two different values, we interpolate between those.
const borderRadius = mix(origin, target, time);
Second, we keep a record of the “actual”, pre-correction value. If the animation is interrupted, the next animation will start from this rather than the final scale-corrected value (which might have no relevance in a future scale context).
this.current.borderRadius = borderRadius;
Finally, we apply the scale correction.
The border-radius
style can be set per corner with styles like border-top-left-radius
. Each corner can accept two values, one for each axis. So to correct for each axis, we divide the current border-radius
once by the scale of each.
const x = borderRadius / scaleX;const y = borderRadius / scaleY;element.style.borderTopLeftRadius = '${x}px ${y}px';
box-shadow
has an x
and y
setting that be corrected in the same way, but it also has blur
and spread
that don’t have single-axis controls. To correct these values, we take an average scale and apply that to both instead:
const averageScale = mix(scaleX, scaleY, 0.5);blur = blur / averageScale;spread = spread / averageScale;
But generally the ratios we animate from/to are similar enough that the blur
and spread
scale correction looks pretty good. In the future, there may be some weighting we can do to stop the more extreme distortions.
Correcting these two styles fixes most of the visual distortion on a component. But we’re still left with the distortion of children.
Correcting child components
Without child correction, the shape of this round ball becomes distorted as its parent changes scaleX
.
In addition, it would be impossible to also try and reliably animate this ball’s x
position, because the space which it travels through would itself be stretching and squashing. This would lead to a very uneven motion like it was sat upon lapping waves.
Correcting child distortion is where we start to pull away from the literal technique of FLIP and adhere to it more in principle.
The first step is to loop through every animating component and remove any currently animating styles. Then, we snapshot their layout in a second pass.
children.forEach((child) => child.reset());children.forEach((child) => child.snapshot());
By batching the reads and writes in this way we prevent layout thrashing. In Framer, a prototype might have hundreds of animating components, so optimizing this can have a profound effect on performance.
We also ensure we’re snapshotting every component as it will exist on the screen in its final state, unaffected by the transform
of its parent(s). This is important because it means we can then track, within a tree, all of the transforms we’ve applied to each component, then use this to correct the appearance of its children.
Because we want to play the animation of every component independently of this tree transform (to avoid the lapping waves), each component has a shadow bounding box. This gets interpolated from its visual origin to its target once per frame.
const latest = mix(origin, target, time);
The target
is usually the same as the measured last layout (the L in FLIP), but for some effects like AnimateSharedLayout
’s crossfade, it might be somewhere else on screen. Either way, we now know where on the screen we want our component to appear visually. We use this information to calculate the delta between where we want the component to appear, and where it actually is.
const delta = calcDelta(latest, actualPosition);
The component saves this delta
to a context that all of its children have access to. The component itself might also have some parent deltas that it has to correct for. So before calculating delta
we first apply all the latest parent deltas to the actual measured position.
const latest = mix(origin, target, t);const transformedPosition = applyParentDeltas(actualPosition, parentDeltas);const delta = calcDelta(latest, transformedPosition);
This is how the scale correction is performed. By applying parentDeltas
to the actual position, we are then left with figuring out how to go from there to our desired visual position.
As a final step, we also use parentDeltas
to calculate the combined scale of the tree. We can use this on the CSS style corrections from before, so they correct parent distortions too.
const x = borderRadius / scaleX / treeScaleX;
Our ball now stays the correct size, and can even animate through scaled space even as it distorts around it.
Wrapping up
Animating layout in the browser is hard, but Framer and Framer Motion are presenting it in an accessible way, removing all the friction from handoff.
Thanks to the foundations of the FLIP technique, we can do this at 60fps. By accounting for tree transformations, its possible to correct scale distortions throughout an infinitely deep tree, on size, position, and even CSS styles like box-shadow
and border-radius
.
See Magic Motion in action and create your own animations by signing up for Framer Web. It’s available for free! If you’re a developer, you can also try out Framer Motion 2’s new auto-animate and shared layout features.
This blog post originally appeared on inventingwithmonster.io.
Bring your best ideas to life
Subscribe to get fresh prototyping stories, tips, and resources delivered straight to your inbox.