Inventing With Monster

The Magic Inside Magic Motion

Matt Perry|09 Apr 2020

Magic Motion is a powerful new feature in Framer that allows designers to draw a line between any two screens and have 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:

Crucially, Framer Motion performs all these layout animations at 60fps. In this this post, we’re going to learn how.

The Problem

CSS offers a number of different layout systems that can all interoperate with each other. A flexbox can be placed with in 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;
}

So developers are limited in their options. Ideally, animations should be performed using cheap compositable properties like transform and opacity. Properties like background-color and box-shadow trigger paint, which is a little more expensive, but sometimes it’s unavoidable.

What we ideally want then, is a way of animating layout using only transforms.

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.

That is, we:

  1. Measure the first layout
  2. Update the CSS and measure the last layout
  3. Apply the inverted delta as a transform to make the last layout look like the first
  4. 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, everything 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.

Try clicking on this box and notice the types of distortions seen when toggling between its 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;

This works pretty well but it isn’t perfect. The limitations of this approach are increasingly obvious with more extreme ratios of x/y distortion:

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 through which it travels through would itself be stretching and squashing. This would lead to 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 optimising 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 just left with figuring out how to get 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:

Conclusion

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.

If you’re a designer, sign up for the Framer beta, and if you’re a developer you can try out the new auto-animate and shared layout features in the Framer Motion 2 open beta right now!