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.
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:
document.startViewTransition(() => {
applyTheFilters(); // whatever DOM change you were going to make anyway
});
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:
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 250ms;
animation-timing-function: ease;
}
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.
/* on the listing page */
.card--active .card__image { view-transition-name: hero; }
/* on the article page */
.article__image { view-transition-name: hero; }
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.
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
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.