You wrap a state change in document.startViewTransition() and the whole page cross-fades. Good. Then you give the card thumbnails a view-transition-name so they’ll glide into the detail view, and now nothing animates at all. No error on the page, no warning in the layout. The click works, the new view appears, and the animation you wrote is gone.
That gap between one line that works and two lines that silently don’t comes from a single misconception: that the browser animates your elements. It doesn’t. By the end you’ll know what it animates instead, which is why names have to be unique, why text can look smeared mid-flight, and why the page stops responding while a transition runs.
One Line Gets You A Fade
Start from the version that works. A card grid, a detail view, and a function that swaps one for the other.
function openCard(card) {
if (!document.startViewTransition) {
showDetail(card); // no support: swap without the animation
return;
}
document.startViewTransition(() => showDetail(card));
}
startViewTransition() takes a callback that changes the DOM. The browser calls it at the right moment and animates between the before and the after. The default is a short cross-fade of the whole page, which beats a hard cut for the price of one function call. The guard at the top is the whole fallback: a browser without the API gets the plain swap.
That’s the pitch, and it’s accurate. The trouble starts when you want something more specific than a fade, because the name misleads. “Transition” suggests the browser is moving your elements from where they were to where they are. It isn’t moving anything.
Two Pictures And A Frame
Here is what happens when you call it, in order.
- The browser captures the current page as an image.
- It pauses rendering and runs your callback, which updates the DOM.
- It captures the new state. This side stays live, so it keeps playing video and running animations.
- It builds a tree of pseudo-elements over the page and animates that tree. The page underneath is hidden for the duration.
Step four is where the name finally means something. Every element with a view-transition-name is lifted out of the page and gets its own group. Everything without a name is left behind in a single group called root, which is why an unnamed page cross-fades as one piece.
Each group holds exactly two pictures: the old one and the new one. The group is a frame that slides and resizes from the old box to the new box. Inside the frame, the old picture fades out while the new one fades in.
Fig. 02 — The group travels from the old box to the new one. Inside it, two pictures cross-fade.
You can style every part of that tree with CSS, because it is made of real pseudo-elements. Naming is one declaration, and the timing lives on the group.
.thumb {
view-transition-name: thumb;
}
::view-transition-group(root) {
animation-duration: 150ms; /* the page fade: quick */
}
::view-transition-group(thumb) {
animation-duration: 450ms; /* the travelling frame: slower */
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
The old page is a photograph. The new page is a window.
Transition_Doctrine // §1
Everything strange about view transitions follows from that. You are not animating a layout. You are sliding a frame around two pictures and running a clock.
Names Must Be Unique
Now the bug from the opening. Here is the version that breaks, because it’s how you’d write a class: every card carries the same name.
.card-thumb {
view-transition-name: thumb; /* on all twelve cards */
}
A name identifies one group, so twelve elements claiming it is a contradiction. The browser’s answer is to skip the transition entirely. transition.ready rejects with an InvalidStateError, the animation never runs, and your callback still runs, so the page updates as normal. That last part is what hides it. The only trace is usually a console message, and only if DevTools was open.
The fix is to name only the card that was clicked, and only for as long as the transition needs it.
async function openCard(card) {
const thumb = card.querySelector(".card-thumb");
thumb.style.viewTransitionName = "thumb";
const transition = document.startViewTransition(() => showDetail(card));
transition.ready.catch((error) => console.warn(error)); // surfaces a skipped transition
await transition.finished;
thumb.style.viewTransitionName = ""; // free the name for the next click
}
The detail view’s hero image carries view-transition-name: thumb in plain CSS. The old state then contains one thumb (the card) and the new state contains one thumb (the hero), and the browser pairs them into a single group that travels between the two boxes. It works because showDetail hides the grid, and an element that isn’t rendered can’t be captured, so the new state never sees the card’s name.
The rule is one per state. The same name may appear once before and once after, and that is how the pairing happens. It may not appear twice in either.
Multi-page sites have the same rule with an extra snag. Cross-document transitions are a single opt-in rule in the CSS of both pages.
@view-transition {
navigation: auto;
}
But the list page has twelve thumbnails and the detail page has one hero, and the names have to match across the navigation. CSS can’t number elements, so the per-item name goes in the markup.
<a href="/posts/7/">
<img src="/img/7.jpg" alt="" style="view-transition-name: post-7" />
</a>
The detail page gives its hero post-7 the same way. Every name on the list page is unique, only one of them exists on both pages, and that one flies.
!
Support differs by kind. Same-document transitions work in current Chrome, Safari and Firefox. Cross-document ones shipped in Chrome and Safari first, so check Firefox before you depend on them.
Pictures Don’t Reflow
Because a named element is two pictures and not a live box, it is never laid out in between. The browser has a picture of how it looked and a picture of how it looks now, and it scales and slides them. Nothing reflows on the way.
That’s invisible for an image and obvious for text. Say a card’s caption wraps onto two lines in the grid and one line in the detail view. Name it, and you don’t see the words rearrange. You see a two-line picture dissolve into a one-line picture while the group resizes around them. A heading that grows from 24px to 64px is a bitmap being scaled, and it looks like one for the length of the transition.
The rule I use: name the things that persist as the same object, which are usually images, frames and avatars, and leave text to the root fade. If a name has to cover text, keep its size close at both ends.
Smears
Naming the caption. Two pictures of the same words with different line breaks dissolve into each other, and no word actually moves.
Holds
Naming the thumbnail frame. The caption stays in root and fades, while the frame is what travels and resizes.
Being lifted out has a second side effect. Each group paints in an overlay above the page, so an ancestor’s overflow: hidden stops clipping it while it travels. A thumbnail the card was cropping can spill past the card’s edge in flight. And where the old and new boxes have very different aspect ratios, check the result with your real art, because that is the case the defaults handle least gracefully.
The Page Holds Still
The screenshot model also explains why the page goes quiet. For as long as a transition runs, what you see is the overlay, and pointer input lands on it and not on the page beneath. Before the animation even starts, rendering is paused from the moment the old picture is taken until your callback settles.
That second part is the trap. The callback may return a promise, and the browser waits for it. Put a network request inside, and the visitor stares at a still photograph of the old page until the server answers.
Here is the version that freezes.
document.startViewTransition(async () => {
const html = await fetch(url).then((response) => response.text());
render(html); // the old screenshot sits there until the network answers
});
Wait first, then start the transition with a callback that does nothing but swap nodes.
const html = await fetch(url).then((response) => response.text());
document.startViewTransition(() => render(html));
The page stays interactive while the request is in flight, and the transition only covers the part that really is a transition. I keep anything a click triggers under about 300ms, because past that the animation stops feeling like feedback and starts feeling like waiting.
Motion preferences need handling too, and the tree makes it three selectors. The DOM update still happens. Only the animation goes.
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
Four Checks Before You Ship
When a transition misbehaves, don’t reason about the animation. Reason about the pictures.
| Symptom | Likely cause | Check |
|---|---|---|
| Element fades instead of travelling | The name exists in only one state | Is there exactly one before and one after? |
| No animation, page still updates | A duplicate name skipped it | Read the transition.ready rejection |
| Text smears mid-flight | Named element changes size or wrapping | Name the frame, not the words |
| A pause before the animation begins | Slow work inside the callback | Move it before startViewTransition() |
On Monday:
- Feature-detect, and let the plain swap happen where the API is missing.
- Name only what persists as the same object, once per state, and set per-item names at click time or in markup.
- Do the slow work first, and keep the callback to swapping nodes.
- Add the reduced-motion rule before the first transition ships.
It’s a clever trick performed with photographs, and it behaves well exactly when you design for that.