← All writing
Craft · · 8 min

Thumbnails and heroes

The animation was never the expensive part, and the browser has quietly taken over the part that was.

CSS JavaScript

“Can the site do this?”

Underneath it was a few seconds of screen recording off the client’s own phone app. You tap a product photo in the grid, the photo lifts and grows and settles in as the hero image at the top of the product page. You never lose track of which photo it was. One gesture, no white flash, no sense of anything having been thrown away and rebuilt.

The site is a server-rendered store. Real URLs, real links, the browser doing the navigating, no router. You tap a product, that page goes away, another page arrives, and the photo you tapped has no relationship whatsoever to the photo that turns up.

I’ve had this conversation enough times to have a routine for it. The morph is a day of work. Getting a document navigation to hold two pages on screen at once, so that there’s something to morph between, is not a day of work, and the honest estimate is the architecture instead of the animation. Then everybody looks at the number and we agree to talk about it next year, as one does.

The uncomfortable part is that they’re right. I can meet every other argument for rebuilding the store as a single-page app. I’ve written most of my rebuttals down at one point or another. This one isn’t really an argument. It’s a difference you can watch, in a demo, on a phone, in a meeting, and “there’s less to go wrong” does not compete with a thing you can watch.

Chrome 111 released the first half of the answer back in March, and the second half is behind a flag. I’ve spent a few evenings with both.

It takes two pictures

The View Transitions API takes a picture of the page as it stands, hands you a callback to change the DOM however you were going to, takes a picture of how it ended up, and animates between the two. The default is a cross-fade. Unglamorous, and most of the perceived benefit.

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

You wrap the update you were making anyway. The browser does the rest, and every stage of the animation is exposed as a pseudo-element, so the timing and the easing are CSS.

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

Same photo, different job

The part that’s actually new, instead of a tidier cross-fade, is that you can tell the browser two elements are the same thing.

Give an element a view-transition-name, give its counterpart in the new state the same name, and the browser animates between the two positions and the two sizes. The thumbnail travels and grows and lands as the hero. You wrote no keyframes and you measured nothing.

names.css
/* the listing page, on the card that was clicked */
.card--leaving .card__image {
  view-transition-name: hero;
}

/* the product page */
.product__image {
  view-transition-name: hero;
}
CSS

Names have to be unique within the document, which is the constraint everything else falls out of. You can’t put view-transition-name: hero on 12 cards at once, so on a listing you put it on the card that was clicked, usually by adding a class.

That effect is, I think, a good part of the reason people build app-like front ends at all. It’s been possible for years with a library, a measuring step and a lot of absolute positioning, the FLIP technique that every animation library has some version of. Now it’s two declarations and nothing measured.

The version with no JavaScript in it

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

The one I care about is the cross-document version. The same transition, across an ordinary navigation, one HTML document to another, with the browser doing the navigating and nothing intercepting the click. It’s behind a flag, in one engine, and the opt-in right now is a meta tag I’d bet money gets renamed before it lands.

head.phtml
<!-- goes on both pages, and both have to be same origin -->
<meta name="view-transition" content="same-origin">
HTML

“No JavaScript” is a small lie. So it’s worth saying which part is the lie. The cross-fade genuinely needs none, on either page. The morph still needs a line of script, because something has to put the name on the thumbnail you tapped before the page goes away, and the browser has no way of knowing which of the 12 it was.

If it lands the way it looks like it’s going to, the conversation at the top of this post is over. Real pages, real URLs, the browser navigating, no bundle, no history to manage, no scroll position to put back by hand, no focus to move after a navigation, and the thing on screen looks like the recording the client sent.

That matters for the kind of sites I build, and not because animation is important. Every SPA I have ever been near spent a real part of its budget rebuilding what the browser already does, usually a little worse: the back button, scroll position, where focus goes, the loading state, the error state, what happens when the connection drops halfway through a route change. Taking away the last good reason to sign up for all of that is worth more than the animation is.

A photograph of where you were

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

And there’s a new failure mode in that, which I didn’t see coming until I throttled the connection. During a cross-document transition the browser is holding a picture of the page you just left while it waits for the next one to arrive. If the next one takes a while, you sit there looking at a photograph of where you were. No spinner, no progress, and not even the white flash, which at least told you something had happened. The tab has a little spinner on it, and nobody in the history of the web has ever watched the tab.

I suspect there’s a timeout in there somewhere, because past a certain wait it gave up on me and did an ordinary navigation instead.

A slow navigation used to look slow. Now it can look broken, which is worse, because slow makes somebody wait and broken makes them tap it again.

So this goes with prefetching instead of standing in for it. Make it fast, then make it smooth, in that order.

Reduced motion, and I mean it

This is a motion feature, and most of what it buys you is a large element crossing the screen at speed. I built a parallax hero once because a client asked for one, and a coworker asked me to close the tab and then had a headache for the rest of the afternoon. I’ve been careful with this since.

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

The cross-fade is usually fine under reduced motion, since nothing travels. The morph is not. That setting doesn’t mean no animation, it means nothing that flies across somebody’s screen, and a photo growing from a thumbnail to full width is the exact shape of the thing it’s asking you not to do.

Late as usual

I keep writing the same observation in different clothes. So I may as well name it. Every few years the platform absorbs a job that used to be a reason to adopt a framework, and the argument for the heavier architecture loses a leg.

Fetching without a page reload, then form validation, then watching for things on screen without a scroll handler, then dates and plurals, and now the transition. Container queries got all three engines this year, after about a decade of people asking. :has() turned up in a single browser after 20 years of being impossible. Subgrid arrived in Firefox in 2019 and is still waiting on Chrome. Every one of them was worth having, and every one took years longer than anybody wanted it to.

My guess is two years before I can put this in a proposal without a paragraph of caveats under it. I’ve been wrong in both directions on that kind of guess before. Which means I’ll lose the argument a few more times first, in a meeting, to a video of a phone 😔

I told the designer not yet, which is not a satisfying thing to say to somebody holding a recording of a thing that plainly already exists. So I spent an evening on the other half of the answer. I turned the flag on, put the names on a copy of the store, and sent back a recording of our own product grid doing the same trick, with a note saying this is real and it isn’t ready. It’s the first time in nearly eight years I’ve been able to say not yet instead of not without rebuilding the whole store. I’ll take not yet.

Read similar posts
2 min

light-dark()

Two color values in one declaration, picked by the color scheme, which removes most of the reason a theme needed a second block of custom properties at all.

9 min

Style queries

A card with 5 surface variants and 4 children that each had to know which one they were sitting in, and 20 selectors across 4 files holding that together until a feature I'd been ignoring since 2024 deleted all of them.