Matt Perry
is writing
code.

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

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:

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;
}

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.

Photo of Matt Perry
Matt Perry is a developer and photographer currently living in Amsterdam.

Twitter | Instagram