View transitions: Handling aspect ratio changes

This post assumes some knowledge of view transitions. If you're looking for a from-scratch intro to the feature, see this article.

When folks ask me for help with view transition animations that "don't quite look right", it's usually because the content changes aspect ratio. Here's how to handle it:

Unintentional aspect ratio changes

It's pretty common for these aspect ratio changes to be unintentional. For example, here's some CSS:

.simple-text {
  font-size: 25vw;

  &.toggled {
    position: absolute;
    bottom: 32px;
    right: 32px;
    font-size: 9vw;
  }
}

And we'll toggle that class when a button's clicked:

btn.onclick = () => {
  document.querySelector('.simple-text').classList.toggle('toggled');
};

Here's the result:

Fine. Ok. But let's make it a view transition. We'll give it a view-transition-name:

.simple-text {
  view-transition-name: simple-text;
}

And wrap our toggle in startViewTransition:

btn.onclick = () => {
  document.startViewTransition(() => {
    document.querySelector('.simple-text').classList.toggle('toggled');
  });
};

And here's the result:

It doesn't seem to animate consistently from one state to the other – at times you can see multiple "Hello!" elements.

This is because it's changing aspect ratio. It's more obvious with outlines added:

Although the text is the same in both views, the box shapes are different.

The initial state is the default position: static and display: block, so it takes up the full width of the parent. When it becomes position: absolute, its taken out of flow, and its size fits the content.

The old and new views don't line up properly, because the old view has empty space to the right-hand side, but the new view doesn't.

We want the element to be the size of the content in both cases, which we can do with fit-content:

.simple-text {
  width: fit-content;
}

Here's the result:

Fixed! But sometimes we want the aspect ratios to be different:

Intentional aspect ratio changes

This time, let's take an element:

<div class="text-in-container">Hello!</div>

…and create a transition where the text changes. So here's the CSS:

.text-in-container {
  view-transition-name: text-in-container;
}

And here's the JavaScript:

btn.onclick = () => {
  document.startViewTransition(() => {
    document.querySelector('.text-in-container').textContent =
      'Hello everyone out there!';
  });
};

And the result of that:

Well, that doesn't look right. It kinda looks like it zooms in.

View transition pseudo-elements have this structure for each independently animating item:

::view-transition-group(text-in-container)
└─ ::view-transition-image-pair(text-in-container)
   ├─ ::view-transition-old(text-in-container)
   └─ ::view-transition-new(text-in-container)

The default transition animates the ::view-transition-group from its old size and position to its new size and position. The views, the ::view-transition-old and ::view-transition-new, are absolutely positioned within the group. They match the group's width, but otherwise maintain their aspect ratios.

But that's just the default. Because view transitions are built on top of CSS, we can alter these defaults. In this case, let's make the views 100% height of their group, rather than maintaining their aspect ratio:

::view-transition-old(text-in-container),
::view-transition-new(text-in-container) {
  height: 100%;
}

The result:

Now it feels like the shape is transitioning properly, but ugh, stretchy text looks bad.

At this point we need to step back and think about what kind of transition we actually want. It feels like:

  • The box should stretch, changing aspect ratio throughout the animation.
  • The text should maintain aspect ratio, but stay within the box.

Since we want to animate these things in different ways, they need to be separate items within the view transition:

<div class="container">
  <div class="text">Hello!</div>
</div>

Now we have two elements, we can target them with CSS:

.container {
  view-transition-name: container;
}

.text {
  view-transition-name: text;
}

::view-transition-old(container),
::view-transition-new(container) {
  height: 100%;
}

The result:

Now our box is doing the right thing, but the text isn't – we want it to stay within the box.

In view transitions, the views are images, so we can style them using things like object-fit:

::view-transition-old(text),
::view-transition-new(text) {
  /* Break aspect ratio at an element level */
  height: 100%;
  /* But maintain it within the image itself */
  object-fit: none;
  /* And hide parts of the image that go out of bounds */
  overflow: clip;
}

And the result:

Much better! We can even use object-position to change the alignment:

::view-transition-old(text),
::view-transition-new(text) {
  object-position: left;
}

And now our text is left-aligned:

Handling shape and size changes

The previous solution works great because the text view is only changing position and shape, not scale.

To make things interesting, let's throw a change of font-size into the mix:

This isn't awful, but we've lost that nice effect where "Hello" moves smoothly between the states.

Usually, when I want an image to react to size changes, but maintain aspect ratio, I'd use object-fit: cover or object-fit: contain. Unfortunately that's a bit tricky here, since we'd want the wider of the two views to be object-fit: cover, and the narrower to be object-fit: contain. That would mean poking at the layout with JavaScript, determining which is which, and applying styles dynamically.

What we actually want to express is something like object-fit: contain-block, where the image is contained on the block axis, but covers on the inline axis. Unfortunately this feature doesn't exist (although I've requested it), so we need another approach.

C'mon, this is CSS, so there's always another approach.

So, throwing away all the previous ::view-transition-* styles, let's start again:

::view-transition-old(text),
::view-transition-new(text) {
  /* Make the text views match the height of their group */
  height: 100%;
  /* Set the other dimension to auto,
     which for images means they maintain their aspect ratio */
  width: auto;
}

::view-transition-group(text) {
  /* Clip the views as they overflow the group */
  overflow: clip;
}

And here's the result:

Oooo, it's so close, but it isn't quite right. If you play it slowly, you can see that the "Hello"s aren't lining up.

This happens because the text element includes the padding, and the padding is the same pixel value in both states. Because the result is a mix of scaled and static values, the images don't line up.

We can solve this by moving the padding to the container:

.text {
  padding: 0;
}
.container {
  padding: 0.4em 1em;
}

And here's the result:

We're so nearly there! The only imperfection is that the clipping is now applying within the padding of the box.

What we really want is to be able to nest our ::view-transition-group(text) in our ::view-transition-group(container), then apply the clipping to the container. This feature is called nested transition groups, but it hasn't been developed yet. So, in the meantime, we can cheat!

The padding on the container is 0.4em 1em, and the font-size is 5.7vw. We can multiply those together to get the effective padding: 2.28vw 5.7vw.

We can expand the clip area of our transition group using overflow-clip-margin. Weirdly, this doesn't accept different values for x and y, so we just take the larger of the two values:

::view-transition-group(text) {
  overflow-clip-margin: 5.7vw;
}

And the result of that:

And there we have it! A nice smooth transition that handles changes of scale and aspect ratio!

Oh go on then, let's throw in some silly easing using the new linear() feature:

:root {
  --spring-easing: linear(
    0, 0.01, 0.04 1.5%, 0.163 3.2%, 0.824 9.2%, 1.055, 1.199 14.2%, 1.24, 1.263,
    1.265 18.2%, 1.243 19.9%, 0.996 28.8%, 0.951, 0.93 34.1%, 0.929 35.7%,
    0.935 37.5%, 1 46.3%, 1.018 51.4%, 1.017 55.1%, 0.995 68.6%, 1.001 85.5%, 1
  );
  --spring-duration: 0.5s;
}

::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
  animation-timing-function: var(--spring-easing);
  animation-duration: var(--spring-duration);
}

And here's the final result:

Err, ok, maybe I went too far. That's why I'm not a designer.

View this page on GitHub

Comments powered by Disqus

Jake Archibald next to a 90km sign

Hello, I’m Jake and that is my tired face. I’m a developer of sorts.

Elsewhere

Contact

Feel free to throw me an email, unless you're a recruiter, or someone trying to offer me 'sponsored content' for this site, in which case write your request on a piece of paper, and fling it out the window.