Matt Perry
is writing
code.

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

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:

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:

Home
Calendar
Mail

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:

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

I must not animate layout. I must not animate layout. I must not animate layout. I must not animate layout.

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:

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:

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?

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:

And WebGL:

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.

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

Twitter | Instagram