← All writing
Craft · · 6 min

View transitions on a multi-page site

An element that morphs across two documents, and a new failure mode where a slow page looks frozen rather than slow.

CSS JavaScript

The conversation goes like this. I make the case for server-rendered pages, real URLs, real links, no router. Everyone agrees it’s simpler, cheaper and more robust. And then somebody says: yes, but it flashes white between pages and it feels cheap.

And they’re right. That’s the bit I’ve never had a proper answer to. Every other argument for a single-page app I can meet, and this one is a real perceptual difference that a client can see in a demo, and “it’s more robust” does not compete with a thing you can watch.

Chrome shipped the first half of the answer in the spring, and the second half is behind a flag, and I’ve spent a few evenings with both.

What it does

The API takes a snapshot of the page as it is, lets you change the DOM, takes a snapshot of how it ends up, and animates between the two. The default is a cross-fade: unglamorous, and about 80% of the perceived benefit.

Within a single document it’s one call:

transition.js
document.startViewTransition(() => {
  applyTheFilters();      // whatever DOM change you were going to make anyway
});
JavaScript

You wrap the update you were already doing. The browser handles the rest, and the animation is stylable through a set of pseudo-elements, so the timing and easing are CSS rather than JavaScript:

transition.css
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 250ms;
  animation-timing-function: ease;
}
CSS

Naming an element across two states

The part that’s genuinely new, rather than a tidier cross-fade.

Give an element a view-transition-name, and give the corresponding element in the new state the same name, and the browser animates between the two positions and sizes. So a thumbnail in a grid becomes the hero image on the article page, traveling and growing, and you wrote no keyframes and measured nothing.

names.css
/* on the listing page */
.card--active .card__image { view-transition-name: hero; }

/* on the article page */
.article__image { view-transition-name: hero; }
CSS

Names must be unique per page, which is the main constraint: you can’t put view-transition-name: hero on 12 cards, so on a listing you assign it to the one that was clicked, usually by setting it in the click handler or via a class.

That effect, the thumbnail-becomes-hero, is a large part of why people build app-like front ends at all. It’s been achievable for years with a framework, a measuring step, and a fair amount of absolute positioning, and now it’s two declarations.

The thumbnail-becomes-hero effect is a decent part of why anybody builds an app-like front end, and it's now two declarations and no measuring.

The multi-page version, which is the point

Everything above still needs a client-side DOM update, so on its own it’s a feature for apps.

The version that matters for the work I do is the cross-document one: the same transition, across an ordinary navigation, from one HTML document to another, with no router and no JavaScript at all. It’s behind a flag at the moment and being specified, and the demos work, and it’s clearly where this is going.

If that lands as expected, then the argument at the top of this post is over. You can have real pages, real URLs, a browser doing the navigating, no bundle, no history management, no scroll restoration to reimplement, no focus bugs, and the transitions look like the thing the client saw on a competitor’s app.

That’s a big deal for the kind of sites I build, and not because animation is important. It’s because every SPA I have ever been near has spent a substantial part of its budget reimplementing things the browser already does, usually slightly worse: the back button, scroll position, focus after navigation, the loading state, the error state, what happens when the connection drops mid-route. Removing the last good reason to take that on is worth more than the animation is.

What it doesn’t do

It doesn’t make anything faster. It makes a wait look intentional.

And there’s a new failure mode in that, which I hadn’t anticipated until I throttled the connection. During a transition the browser is holding a snapshot of the old page. If the new page takes a long time to arrive, you’re looking at a frozen picture of where you were, with no spinner and no progress and no white flash to tell you something is happening.

A slow navigation used to look slow. Now it can look broken. Which means this pairs with prefetching rather than replacing the need for it, and the honest ordering is: make it fast, then make it smooth.

Reduced motion, and I mean it

This is a motion feature and a lot of what it enables is large elements traveling across the screen at speed, precisely what I wrote about in 2018 as the thing that makes some people ill.

motion.css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}
CSS

The cross-fade is generally fine even under reduced motion, since it doesn’t travel. The morphing hero is not, and the default should be off for anyone who’s asked for less.

One leg shorter each time

I keep writing versions of the same observation, so I may as well name it: this is another case of the platform absorbing something that was previously a reason to adopt a framework.

Fetching without a page reload, then form validation, then observers instead of scroll handlers, then dates and plurals, and now the transition. Each time the argument for the heavier architecture gets one leg shorter, and each time it takes about five years longer than anybody would like.

I’d guess this one is two years from being usable on client work, which means I’ll lose the argument a few more times first. Fine. I’ve been losing it since about 2015 and I’ve got the hang of it.

Read similar posts
6 min

Style queries

Five surface variants, four child elements, and 20 selectors that existed only to tell a child which parent it was in. Style queries delete the whole grid of them. That isn't quite the same as saying you needed them.

6 min

currentColor

I opened a button component with 11 color declarations in it, across four variants, and the correct number was one per variant, because six of those properties already default to the thing they were being set to.