Skip to content

Instantly share code, notes, and snippets.

@taowen
Last active March 19, 2024 13:19
Show Gist options
  • Star 16 You must be signed in to star a gist
  • Fork 11 You must be signed in to fork a gist
  • Save taowen/e102cf5731e527cb9ac02574783c4119 to your computer and use it in GitHub Desktop.
Save taowen/e102cf5731e527cb9ac02574783c4119 to your computer and use it in GitHub Desktop.

Layout projection: A method for animating browser layouts at 60fps

Introduction

Animating layout is hard. Prohibitively so; how many ambitious designers have provided dev teams dazzling videos of app-quality UI animations, only to be rebuffed?

If you're a web developer, what would your reaction be if asked to realise this kind of App Store-style interaction, where an item opens into a full-screen view when clicked:

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.mp4

Chances are, the two static layouts would be implemented without even an attempt at building the transition. The rationale would be numerous and legitimate: The time constraints versus the sheer complexity of implementation, or the ongoing maintenance of a codebase left in a state of late-game Jenga.

Maybe even a gut feeling that this kind of animation is literally impossible on the web, no less at a smooth 60 frames per second?

These reasons (and perhaps more) would burst spiralling out of your weather-worn spider senses, straight into the ears of yet another deflated design team.

Designers: This isn't a lack of matching ambition. Layout animations on the web present a range of formidable technical and practical barriers.

In this short series of posts, I'll provide an overview of the layout animation problem and some of the solutions attempted to date.

Then, I'll present layout projection, a new method of animating layouts using the performant CSS transform property. An early implementation of it already powers Magic Motion in Framer and the layout animation features in Framer Motion.

Framer Motion presents this complex functionality behind a minimal, declarative API. In the opening App Store demo, all the layout animations were created with this bit of markup:

<motion.div layout />

Making the implementation of layout animations easy to write, read and maintain means it's more likely they'll be implemented at all. If a feature is successful you won't be saddled with a delicate codebase. If it isn't successful then you've wasted far less time on polish.

The layout prop is also great for creating state-driven layout animations, like reordering the items in a list and automatically animating changes in the output:

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.1.mp4

A common request is being able to animate height: auto as content is added/removed. Better to do it with transform:

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.2.mp4

We can even use this simple API for shared element transitions. By replacing layout with a unique layoutId, we can animate new elements from old ones, as if they were one continuous element:

isSelected && <motion.div layoutId="underline" />;
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.3.mp4

The user-facing API for layout animations is minimal. But internally, too, the layout projection technique is abstracted to a simple viewport-relative bounding box.

We'll see how this viewport box abstracts the complexities of layout projection so practically and mentally we can concentrate on using it to create advanced animations like shared element transitions and drag-to-reorder. We'll also learn about explorative work to leverage this viewport box to incorporate SVG and WebGL into our performant layout animations.

Framer Motion is a React library, but in the final part we'll do a technical dive into the key beats of a functional implementation. By the end, I hope to share enough of an understanding of layout projection that a reader could implement their own library around this technique.

First, let's take a closer look at layout animations, why they're so difficult to perform, and some of the solutions attempted so far.


The problem of layout animation

Animating browser-computed layout isn't a new problem, it's been difficult for decades.

It's easy to imagine why. CSS offers a number of layout systems; grid, flexbox, floats, and more. The final calculated layout of any web page is an interaction between the rules placed on these systems and the HTML hierarchy they're applied to.

Owing in part to this flexibility/complexity, these layout calculations are often too expensive to run at the 60fps required for smooth animations.

Furthermore, these layout systems are systems of constraints, the output from which is often the result of discrete rules. What does an interim state between flex-start and flex-end look like? Or between display: grid and display: flex? Or animating from three columns to one?

No layout is calculated in isolation. The computed size and position of one element affects those in and around it. Therefore, it's just as odd to want to animate these obviously problematic discrete systems as it is for more common wishlist items, like height: auto.

In fact, it is already possible to animate some explicit layout rules like setting width and height to an explicit value.

width: 200px;
transition: width 1s ease-out;

But let's see this in action.

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.4.mp4

As a simple page, this animation should actually perform fairly well on modern devices, but there's a few things to notice.

The first is, as part of the layout calculations, width is being rounded to full pixels. Especially at low velocities, this can make the animation feel janky.

The second, in my opinion more important point, is that the line-wrapping is (understandably) a system of binary break points. A word either wraps or it doesn't, and this changes throughout the animation, leading to this constant re-wrapping. It looks terrible.

So even when animating layout is performant, even when we are animating seemingly straightforward values, I don't believe that the resultant visual output is something we should bother to pursue anyway.

Instead, what we want is a system that lives apart from the nexus of CSS layout and its discrete rules. One that lets the browser calculate the layout and then animates between the raw image output of each.

Not only would this kind of system look and perform better, it would work with any calculated layout system and work out-of-the-box with arbitrary systems created via the (allegedly) upcoming Houdini Layout API.

Attempts have been made at this kind of system, not only in browsers but also in other calculated layout systems. Layout projection has its roots firmly in these previous systems, so let's take a brief look.

Prior art

Animating layout isn't a browser-specific problem. Any constraints-based layout system will suffer some or all of the above problems.

Microsoft has a whole series of patents around layout animations, including one from 2009 based on its Windows Presentation Foundation (WPF) UI framework. It describes a multi-step process that every layout animation method broadly adheres to:

  1. Snapshot the current layout
  2. Apply the new layout and snapshot
  3. Use the delta between snapshots to put any moved elements back in their previous size and position
  4. Animate the deltas to zero

I'm not familiar with WPF but it's interesting from the paper that it seems to suffer many of the same limitations as the DOM and CSS.

For instance, it mentions that animating one element's layout could affect the layout of others, just like the DOM. The proposed solution is to pause the broadcasting of layout updates for the duration of the animation, which isn't a privilege we have in the browser but an interesting implementation detail all the same.

When discussing the poor performance of animating layout systems, it proposes wrapping the animating elements in a non-layout managed layer and taking direct control of size and positioning.

The browser analogue to this approach might be wrapping the animating element with another absolutely-positioned container. The new contain rule could isolate layout changes in this wrapper from surrounding elements.

.isolate {
  contain: size layout;
}

But animating such an element via top or width would still trigger some layout, so this wouldn't yield scalable performance benefits (or any if we consider full-hierarchy shared element transitions).

Management of this extra wrapper is also fraught with its own complexity and performance implications.

Luckily, CSS does provide a rule that can change the size and position of an element without triggering layout calculations: transform. Which brings us to the current go-to method of layout animations on the web, FLIP.

FLIP

Created by Google's Paul Lewis, FLIP is a method for performing layout animations using transform.

FLIP is an acronym for First, Last, Invert, Play, the four steps of a layout animation as described in the Microsoft patent.

The key difference with FLIP is the "I", Invert. It means calculating a transform that will make an element, from its new layout, look like it's in the size and position of its old one:

const x = prev.left - current.left;
const y = prev.top - current.top;
const scaleX = prev.width / current.width;
const scaleY = prev.height / current.height;
element.style.transform = `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`;

Once this inverse delta has been calculated, and the element is visually back in its old layout, performing the animation is a matter of animating x/y to 0 and scale to 1. With CSS, we can do this by simply removing the transform:

requestAnimationFrame(() => {
  element.style.transition = "transform 300ms ease-out";
  element.style.transform = "";
});

FLIP is great because it's easy to understand, achievable using only CSS animations, and about as performant as a browser animation can get. But it does come with its own set of drawbacks.

Animating position using a transform is straightforward:

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.5.mp4

But when we start animating an element's size via the scale transform, we introduce scale distortion. Notice, in this next example, that both the parent's border-radius and white child element both change size throughout the animation, even though they're the same size in both layouts they're animating between:

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.6.mp4

This isn't only a visual distortion: As the parent animates its size via scale, it's also scaling the coordinate space for all of its children.

Practically, this means that if a parent has a scale of 0.5, and its child has a translateX of 100px, the child will only appear to move 50px on screen:

scale

Animating such a child's position with any kind of control over its exact position or easing curve is simply impossible with CSS if that parent is constantly animating this scale value.

Because of these drawbacks, the complexity curve from the simple layout animation to more advance use-cases like a shared element transition remains insurmountably steep.

Layout projection keeps the same principle of using transform to change the layout but as we'll see, fixes these problems and opens up further possibilities.


Layout projection overview

In essence, layout projection is the ability to use a CSS transform to project any element from its browser-computed layout to a size and position of our choosing.

As I mentioned in the introduction, the intricacies of how this is technically achieved are abstracted away behind the viewport box data model. This simplifies both our code and our mental model when tackling tasks that were previously considered complex.

So before we delve into the technical nitty gritty, let's take a look at the viewport box and how we can apply it to solve a wide range of use-cases.

The viewport box

The size and position that we want to project an element into is defined by the viewport box, a viewport-relative bounding box.

This kind of structure might sound familiar, because "viewport-relative bounding box" also describes the DOMRect, which you might recognise as the return type of Element.getBoundingClientRect().

We can use a DOMRect-esque structure to define our target bounding box:

const viewportBox = {
  top: 0,
  left: 0,
  right: 100,
  bottom: 100,
};

Knowing that this viewport box is where our element will appear on screen, it's all we need to know in order to use layout projection for practical purposes. So what are those use cases, and how do we solve them with a viewport box?

1. Layout animations

To perform layout animations we don't have to stray far from the beaten track. Just like the Microsoft and FLIP implementations to perform layout animations, we first need to measure the element in its old layout:

const prev = element.getBoundingClientRect();

Then we measure it again after the browser has computed the new layout:

// Remove existing layout projection transforms before measuring
element.style.transform = "";

const current = element.getBoundingClientRect();

The key difference with layout projection is that we don't need to compare these bounding boxes, or calculate inverse deltas. We simply animate the element's viewport box from one to the other:

animate({
  from: 0,
  to: 1,
  onUpdate: (progress) => {
    viewportBox = {
      top: mix(prev.top, current.top, progress),
      left: mix(prev.left, current.left, progress),
      right: mix(prev.right, current.right, progress),
      bottom: mix(prev.bottom, current.bottom, progress),
    };
  },
});

// animate and mix available in Popmotion: https://popmotion.io

As we update the viewport box of an element, layout projection will ensure it appears at the correct position every frame.

If we're animating an element's size, we can also provide a viewport box to immediate children affected by scale distortion. Layout projection will ensure they are sized correctly throughout the animation:

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.7.mp4

2. Shared element transitions

Shared element transitions, where two separate elements animate from one to the other, can be performed in one of two ways.

The first is an immediate shared transition. Take this example from before:

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.3.mp4

Each menu item here has its own underline element that's rendered only when that menu item is selected:

function MenuItem({ isSelected, title }) {
  return (
    <li>
      {title}
      {isSelected && <motion.div layoutId="underline" />}
    </li>
  );
}

Before we unmount a component with a given layoutId, we measure its bounding box. When a new component with that same layoutId is mounted, we pass it this bounding box as prev. From there it can perform the layout animation exactly as in the last example.

It isn't more complicated than that! Layout projection ensures that no matter where the new element is in the layout, it animates out from the old element as if it were the same one.

This works great if the content of the two elements we're animating to/from are largely unchanged. But in some cases the content in each element might be quite different. In these instances, we can use a crossfade transition.

It's how Apple achieves a shared element transition between app icon and app:

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.8.mp4

Or take even an extreme example like our text box from before. We can clean this up with a crossfade:

missing

To achieve this crossfade effect, there's a couple extra steps.

Perhaps the most obvious is the need to keep the exiting element in the document until the animation is complete, as we need to animate both elements in tandem. In React, Framer Motion's AnimatePresence component can be used for this, but your view library of choice (if any!) will have its own method.

Ensuring both elements perform a synchronized layout animation is simply a matter of pointing the old element to the new element's viewport box.

By making the elements share a viewport box, layout projection will ensure that no matter where each element actually is in the DOM layout, they will appear to perfectly overlap on-screen.

Then, it's a matter of fading the old root element out as the new one fades in:

const prevOpacity = mix(1, 0, progress);
const currentOpacity = mix(0, 1, progress);

3. Drag-to-reorder

So far, we've used viewport boxes to tackle classic layout animation problems. But we can go further.

For example, take dragging. If we apply a pan gesture to a viewport box:

element.addEventListener("pointerdown", (e) => {
  originViewportBox = element.getBoundingClientRect();
  originX = e.clientX;
  originY = e.clientY;
});
element.addEventListener("pointermove", (e) => {
  viewportBox = applyOffsetToBox(originViewportBox, {
    x: e.clientX - originX,
    y: e.clientY - originY,
  });
});

We can create a simple draggable element:

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.9.mp4

Of course, this is achievable by applying the pan gesture to the element's x/y transform too. But, by applying it to the element's viewport box, a whole range of use cases open up.

For example, a shared element drag gesture. Try dragging this element between the left and right side:

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.10.mp4

If you inspect the DOM as you do this, you'll see that every time the color of the div changes, we're actually rendering an entirely new element in a different part of the DOM. On mount, the new element receives the old element's viewport box and can resume the gesture seamlessly from there.

This could be useful for dragging elements between lists. But what about dragging elements within a list?

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.11.mp4

Obviously BYO reordering logic, but once that's in place the animations themselves are relatively easy.

The reordering animations are just normal layout animations, as before.

For the dragging element, we need to do even less. Because we're applying the drag gesture to the element's viewport box, we just have to ensure its underlying layout measurements are up-to-date when the list state reorders and not perform a layout animation when it does so. Layout projection will ensure it remains stable under the pointer even as the element moves around the DOM.

These kinds of effects, once a substantial time investment, are almost side-effects to a proper layout projection implementation.

4. SVG and WebGL

So far, all the examples I've shown are possible to implement today with the APIs available in Framer Motion. This next idea is purely explorative, but I include it to suggest that there might be further applications for layout projection than what we've covered so far.

When we animate the layout of an image, we have to be careful to handle any changes to its aspect ratio. With static images, we might do this with a cropping container, or a crossfade. But images produced by svg and canvas are not static, they're produced programmatically.

This gives us an opportunity to use the viewport box to pre-distort these images so that, after layout projection, they look correct relative to the viewport.

I've made an attempt at both SVG:

Combining SVG viewBox with Framer Motion's layout animations.

Demo: https://t.co/VERSGcw9i0 #viewBoxChallenge

Illustration by @grazsebastian pic.twitter.com/JQnYTK501j

— Matt Perry (@mattgperry) August 27, 2020

And WebGL:

Implemented using @framer Motion's layout animations and React Three Fiber.@0xca0a made this stunning recreation of @marcinignac's original, and we're working towards an API that can mix DOM layout animations with the 3D world.

No clipping, just transforms between CSS layouts. https://t.co/qy004n2CQV pic.twitter.com/Ssi80x7OBC

— Matt Perry (@mattgperry) July 30, 2020

In both examples, I've used the viewport box to widen or narrow the camera on the scene, and keep the scene itself static relative to the viewport. This isn't necessary, it could be used for simple aspect ratio correction, but shows some of the other possibilities here.

To pre-distort the svg, we first need to set its preserveAspectRatio attribute to "none":

<svg preserveAspectRatio="none">

This will tell the browser not to be clever about rendering the SVG "correctly" if its layout aspect ratio is different to its internal aspect ratio. From there, once per frame, we can set its viewBox attribute to the latest viewport box:

const { top, left } = viewportBox;
const viewBox = `${left} ${top} ${width(viewportBox)} ${height(viewportBox)}`;
svg.setAttribute("viewBox", viewBox);

Doing the same in WebGL is a little more involved but works on a similar principle. Using react-three-fiber, we need to tell the WebGLRenderer when our canvas element changes layout:

gl.setSize(width(layout), height(layout));

Then, whenever we update the element's viewport box, we can use that to update WebGL's camera aspect ratio:

camera.aspect = width(viewportBox) / height(layout);
camera.updateProjectionMatrix();

Now that the aspect ratio is being generated correctly relative to the viewport box, we can perform all kinds of effects like the camera animation above and be confident that they will look as expected.


We've seen what the viewport box is useful for, but for any of this to work we need to build the layout projection pipeline. Let's take a look at what that looks like.

Technical implementation

So far, the viewport box has been treated as some magic data model that you can set, and the changes will be reflected on screen.

As mentioned, this is for good reason. Individually, any one of the problems mentioned can be a mind-bender. What's nice about layout projection is that all of these problems can be considered abstractly, through the viewport box. This makes each problem easier to consider and solve.

Sadly, layout projection isn't actually magic. We still need to create a robust DOM rendering pipeline that can reliably project elements into viewport boxes, even at the pit of an n-deep projection tree.

If you just wanted to get the overview of layout projection and this is your stop, you can start making things with it today in Framer Motion. If you're interested in the technical info, read on.

Pipeline overview

The layout projection pipeline consists of a number of essential steps that we'll consider in turn. These are:

  1. Create a tree of all layout-projected elements
  2. Maintain up-to-date layout measurements
  3. Calculate the projection
  4. Build the projection
  5. Correct remaining scale distortion

In this walkthrough we're only going to apply essential performance optimizations for simplicity of explanation. So if some of the following strikes you as costly, it's worth knowing there are optimisations (like mutability) that you can explore, and that in Framer it's browser rendering that becomes a problem sooner than the JavaScript.

Projection tree

When we apply a transform to an element, we are also transforming its children in the same way.

Therefore, to accurately calculate an element's projection, first need to calculate its parent projections and then apply those projections to the element's measured layout.

So for many of the following steps we need the ability to traverse these elements in a tree that reflects their relative place in the underlying DOM hierarchy.

How you create this tree structure is entirely to your preference, but for the purposes of this walkthrough we're going to create this ProjectionNode for every element:

interface ProjectionNode {
  parent?: ProjectionNode;
  children: Set<ProjectionNode>;
}

Using a view library like React makes this simple; parent components can pass their ProjectionNode to children via context, to which they can subscribe on mount and unsubscribe on unmount.

The exact process in other view libraries, or vanilla JavaScript, will be different, but the principle is the same.

Layout measurement

To accurately calculate the projection transform for a given element, we need to maintain up-to-date layout measurements.

In Framer Motion, we use the useLayoutEffect hook to ensure layouts are taken synchronously after every DOM mutation.

As we saw earlier, this measurement is taken using Element.getBoundingClientRect(). However, this returns a bounding box of the element's visual appearance, not its layout. If it, or any of its parents, already has a projection transform applied, we need to reset that first.

measure() {
  // Reset projection transform
  element.style.transform = "";
  
  // Measure layout
  this.layout = element.getBoundingClientRect();

  // Update child measurements
  this.children.forEach((child) => child.measure());
}

Writing to style is a DOM write operation, and measuring using getBoundingClientRect is a DOM read operation. If we were to simply traverse the ProjectionNode tree, resetting and then measuring each element in turn, we would trigger layout thrashing, which could be fatal for performance.

Instead, we need to batch these reset and measure calls. Starting from the root node of the affected branch, we first need to reset the projection transform and that of all the ProjectionNode's' children:

reset() {
  element.style.transform = "";
  this.children.forEach((child) => child.reset());
}

Then we can measure all of these elements in a second step.

measure() {
  this.layout = element.getBoundingClientRect()
  this.children.forEach((child) => child.measure());
}

Calculating the projection

With our up-to-date measurements and our viewport boxes, we're ready to calculate our projection transforms.

Starting from the root ProjectionNode, we traverse the tree, updating the transform of each element:

requestAnimationFrame(() => rootNode.updateTransform());

We generate our transforms as objects with this structure:

interface Transform {
  x: AxisTransform;
  y: AxisTransform;
}
interface AxisTransform {
  originPoint: number;
  scale: number;
  translate: number;
}

We can see that we have three transform values we need to calculate for each axis.

The first is originPoint. This is the point, relative to the viewport, from which the element will transform.

This is important to know, because we're going to use it to apply the calculated scale and translate for this element on every child's measured layout before calculating their projection transform. This is the process responsible for eliminating scale distortion on children.

Assuming a transform-origin of 50%, we can calculate the originPoint of an axis by calculating the halfway point between its defined bounds:

const originPoint = mix(layout.left, layout.right, 0.5);

Next, scale is calculated much the same way as it is in FLIP, by dividing the length of each viewport box axis by that of the measured layout:

const scale = width(viewportBox) / width(layout);

Finally, translate is calculated by measuring the delta between the layout originPoint and another one calculated for the viewport box:

const viewportBoxOriginPoint = mix(viewportBox.left, viewportBox.right, 0.5);
const translate = viewportBoxOriginPoint - originPoint;

Once we've calculated the projection transform for both axes, we can do so for this ProjectionNode's immediate children.

As mentioned, once we apply a projection transform to an element, all of its children will be transformed in exactly the same way. This will render their measured layouts invalid and subsequent projection calculations incorrect.

To correct for this, before calculating a child's projection transform we first need to iterate through a child's parents from the root down. We apply each ancestor's calculated projection transform to the child's measured layout.

In Framer Motion, each element stores its ancestors in an array called the treePath:

let correctedLayout = layout;
treePath.forEach(({ transform }) => {
  correctedLayout = applyTransform(correctedLayout, transform);
});

This applyTransform applies a given Transform to the measured layout.

function applyTransform(layout, transform) {
  return {
    top: transformPoint(layout.top, transform.y),
    left: transformPoint(layout.left, transform.x),
    right: transformPoint(layout.right, transform.x),
    bottom: transformPoint(layout.bottom, transform.y),
  };
}
function transformPoint(point, { originPoint, scale, translate }) {
  const distanceFromOrigin = point - originPoint;
  const scaledPoint = originPoint + scale * distanceFromOrigin;
  return scaledPoint + translate;
}

Once it's done this for all ancestors, we're left with a corrected layout. This is the true size and position of the element, the actual bounding box from which we can calculate the projection. For this, we can use this correctedLayout in place of layout in the projection calculations above.

Building the projection transform

Once we have up-to-date projection transforms for every element, we can apply them.

The translations used to calculate the projections are always relative to the viewport coordinate space: One pixel to one pixel. But as previously explained, when we apply scale, it also scales the coordinate space of an element and all of its children.

We can correct for this by maintaining a cumulative tree scale as we iterate over the treePath in the previous step:

const treeScale = { x: 1, y: 1 };
treePath.forEach(({ transform }) => {
  correctedLayout = applyTransform(correctedLayout, transform);
  // Update tree scale
  treeScale.x = treeScale.x * transform.x.scale;
  treeScale.y = treeScale.y * transform.y.scale;
});

Then, when we build the transform, we can divide the translation by this treeScale, ensuring the element moves the correct distance relative to its scaled coordinate space:

const { x, y } = transform;
const xTranslate = x.translate / treeScale.x;
const yTranslate = y.translate / treeScale.y;
element.style.transform = `translate3d(${xTranslate}px, ${yTranslate}px, 0) scale(${x.scale}, ${y.scale})`;

Style scale correction

At this point, we have created the bulk of layout projection. We're now correctly projecting into viewport boxes without scale-induced distortion!

But there is some distortion remaining, on styles that are bound to the physical dimensions of the element. Check out the border-radius of the purple box in this next example. It should remain static throughout the animation but shows obvious signs of distortion as it flicks between layouts:

missing

We have all the data we need to fix this. But there is a cost. Setting border-radius triggers paint, which is slower than the composite-triggering transform and opacity that we've been using so far. Even so, it's still faster than triggering layout.

Practically, correcting border-radius is a matter of resolving the current radius against the viewport box, rather than letting the browser do it relative to the size of the element's layout:

function correctBorderRadius(latest, viewportBox) {
  const x = pixelsToPercent(latest, width(viewportBox));
  const y = pixelsToPercent(latest, height(viewportBox));
  
  return `${x}% ${y}%`;
}

function pixelsToPercent(pixels: number, length: number): number {
  return (pixels / length) * 100;
}

missing

box-shadow is a little more involved but follows a similar principle, and also only triggers paint. Framer Motion automatically corrects both.

One property that is quite poorly corrected is border-width. There is an implementation of this in Framer's Magic Motion but, because it triggers layout it starts to nullify many of the performance benefits we've won from using layout projection.

In addition, transform, border-radius and box-shadow are all great because they can render with a sub-pixel fidelity. As a layout property, border-width isn't rendered at values thinner than 1 point so it doesn't look perfect when animating between two extremely different aspect ratios. Even so, crossfade manages to hide much of this imperfection.

As we'll see in the next section, this isn't the only drawback to layout projection.

Downsides

Layout projection is powerful and can be applied in a variety of ways. But it does have a couple of serious drawbacks.

Here, I've tried to stick to problems that you are very likely to encounter as someone creating a library around the technique based on the previous section, rather than things that are simply bugs in Framer Motion's implementation.

So at the risk of sounding like a job interview dickhead, the first:

It’s too good

Bear with me!

An inherent output of layout projection is its scale correction. It's extremely robust, but the most robust (and simplest) implementation is ensuring all ProjectionNodes are always "on", responding to any changes in parent scale.

This causes problems. Take the app icon example from before. To create it, I wanted to reuse the first demo of this post, the App Store-style demo. So I nested it neatly in my faux home screen component, which lead to this effect:

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.12.mp4

Notice how the app icon performs the correct animation, as before, but the app itself appears to just fade in and out, static on-screen.

What's happening here is that in the original App Store-style demo, all the elements had the layout prop, so when we click between its item list view to the full-screen view these elements smoothly animate between layouts.

But now, these same elements are nested inside a shared element crossfade transition. It's a different animation, but these children are still performing layout correction to perfectly counteract the scaling effect!

Take the draggable component from before, now with an actively layout-projecting child:

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.13.mp4

The child completely counteracts the movement of its parent!

In a way this stuff is kind of cool, and I'm sure in the future it will have some application. But it certainly isn't what we want in these instances.

This is currently marked as a bug in Framer Motion, and I'm exploring a few ways to fix it.

One would simply to be more aggressive in disabling layout projection, or perhaps switching to a scale-only mode, when a whole tree branch has finished a layout animation.

Another way, that I'm currently leaning towards, would be to derive each element's viewport bounding box relative to its parent projecting element. This would enhance layout animation orchestration and fix both of the examples above, at the expense of a more complex implementation.

Scroll

The eagle-eyed amongst you might have noticed that we've been using a viewport-relative box to project into, but we haven't updated that box when the page scrolls.

This isn't a problem in itself, as we also don't update the measured layout with page scroll either. So the measured layout and viewport box, and thus the calculated projection transform, all remain relatively correct throughout the scroll.

There is one instance where this does become a problem. When crossfading between two different DOM trees, where one of those trees responds to scroll and the other doesn't (like a position: fixed element), the common viewport box reference is broken.

Theoretically it'd be possible to incorporate scroll offset into the projection calculations of the fixed element, but scroll-bound animations often suffer jitter as many browsers prioritize updating the screen with scroll over requestAnimationFrame updates.

Measuring window.scrollY also unfortunately triggers layout so it isn't performant to get this offset to begin with.

Until there's a way around these limitations, blocking page scroll during these transitions is probably the best way to fix it.

The main thread

Most parts of layout projection are currently bound to the main thread.

The viewport box data structure has to be animated by a JavaScript animation library as we can't get raw values out of the Web Animation API or CSS transitions. Or we're generating it from pointer events. In addition, the projection calculations for n-deep trees need to run synchronously down the tree.

This is all surprisingly lightweight - profiling extreme implementations in Framer prototypes show that rendering will be the bottleneck long before layout projection.

But what it means is that if the page is performing heavy workloads you might notice that in your layout animations. One of the best applications is shared element transitions, a time we're more likely to be loading new data or rendering new content.

As always, you should complete big tasks in the 100ms perceptual window before triggering animations.

Maybe in the future it'll be possible to write something off-thread with the Animation Worklet or WebAssembly. Neither of these are ready for even basic animation tasks, so it might be a while yet.

Until then, it is necessary to use a JavaScript animation library like Popmotion.

Conclusion

Despite these downsides, even the early implementation of layout projection found in Framer Motion can be used to tackle tasks that were once insurmountably difficult.

Layout animations and shared element transitions are two areas in which the web experience always falls behind the app equivalent.

It's my hope that after reading this post, people will feel inspired to create more libraries incorporating layout projection to help developers across the front end ecosystem close down this quality gap.

Perhaps, as layout projection matures, and the kinks of implementation are smoothed out, the problem space better understood, we can start to work towards a native browser API that would alleviate the dependency on external libraries.

@taowen
Copy link
Author

taowen commented May 6, 2022

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.mp4
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.1.mp4
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.2.mp4
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.3.mp4
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.4.mp4
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.5.mp4
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.6.mp4

scale

Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.7.mp4
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.8.mp4
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.9.mp4
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.10.mp4
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.11.mp4
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.12.mp4
Y2Mate.is.-.Inside.Framer.Motion.s.Layout.Animations.-.Matt.Perry-5-JIu0u42Jc-1080p-1651813013467.online-video-cutter.com.13.mp4

@badalsaibo
Copy link

Thanks for this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment