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

#### Contents

## 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:

- Create a tree of all layout-projected elements
- Maintain up-to-date layout measurements
- Calculate the projection
- Build the projection
- 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 transformelement.style.transform = "";// Measure layoutthis.layout = element.getBoundingClientRect();// Update child measurementsthis.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 `transform`

s.

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 scaletreeScale.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.