Picture the last time you wired up a page transition. The leaving page's exit. The entering page's entrance. The awkward in-between where both exist at once. Maybe a getBoundingClientRect() here, a hand-tuned transform there, a library install to coordinate it all.
That whole apparatus was for an effect a native app gets by existing.
Here's the part that'll annoy you: the browser does it now. The simplest version — a smooth cross-fade across your entire site — costs exactly one CSS rule. The fancy version, where a thumbnail morphs into a full hero image as you navigate, costs two. And neither one needs a single line of JavaScript. Let me show you the one-rule version first, then build up to the morph.
One rule. The whole site cross-fades.
If you've got a regular multi-page app — separate HTML documents per page — opt into automatic transitions with this:
@view-transition {
navigation: auto;
}
Drop it in your stylesheet. Navigate. Every page change is now a gentle cross-fade. No JS, no library, no event listeners, no coordination.
What's the browser actually doing? It screenshots the outgoing page, runs the navigation, then animates from that screenshot to the new page — a 300ms opacity cross-fade by default. Exactly the baseline you'd have built by hand. And it's strictly opt-in: without that rule, navigation stays instant, same as always.
That's the freebie. But "everything cross-fades" isn't why this API matters. Hold that thought.
The mental model that makes the rest click
Every view transition is the same three beats:
- Capture — the browser snapshots the current page.
- Change — your navigation (or DOM update) runs.
-
Animate — the browser builds two pseudo-elements,
::view-transition-oldand::view-transition-new, and tweens between them.
This is the opposite of how a motion library thinks. A library makes you describe the motion: this exits left, that enters right, here's the easing. View Transitions make you describe what should transition — the browser figures out how by diffing before against after.
Want something other than a cross-fade? Style those pseudo-elements like anything else:
::view-transition-old(root) {
animation: 250ms ease-in both fade-out, 250ms ease-in both slide-out;
}
::view-transition-new(root) {
animation: 250ms ease-out both fade-in, 250ms ease-out both slide-in;
}
@keyframes slide-out { to { transform: translateX(-30px); } }
@keyframes slide-in { from { transform: translateX(30px); } }
Now every navigation is a subtle directional slide. Still zero JavaScript.
The trick worth the price of admission
Here's the thought you parked earlier. The real payoff isn't the cross-fade — it's telling the browser "this element on page A is the same element as that one on page B." Give them a shared name and the browser morphs one into the other.
/* list page */
.product-card[data-id="42"] img { view-transition-name: product-hero-42; }
/* detail page */
.product-hero-image { view-transition-name: product-hero-42; }
Navigate from the list to the detail page and the thumbnail flies into place as the full hero — position, size, shape, all interpolated. You wrote no animation. You declared an identity, and the browser handled every pixel of geometry.
Be honest: how much code did this take last time? getBoundingClientRect() on both elements, the delta math, a transform to fake the start position, timing to undo it. A few hundred lines, easy. Here it's two rules whose names match.
One gotcha: view-transition-name must be unique in the DOM at any instant. Rendering a list of cards? Set the name dynamically so only the card being navigated carries it, or scope it with :has() to the active state.
SPAs get the same engine, behind a function
If you own navigation programmatically, the JS API exposes that same capture-change-animate cycle:
// before:
updatePageContent();
// after:
document.startViewTransition(() => updatePageContent());
Pass a callback that mutates the DOM. The browser captures before, runs your function, captures after, plays the transition. Your update logic doesn't change — you wrap it. React Router and the Next.js App Router are threading this into their navigation primitives, but underneath, the primitive never changes: snapshot, update, animate.
Adding it can't break anything
This is the part that should make you comfortable shipping it today: it's purely additive. Browsers without support — and the holdouts are thinning, with Chrome, Firefox, and Safari all shipping it — just navigate instantly, like they always did. The guard is a one-liner:
if (document.startViewTransition) {
document.startViewTransition(() => updatePageContent());
} else {
updatePageContent();
}
No polyfill, no fallback CSS, no broken state. The browser plays the transition or it doesn't.
Where it fits — and where it doesn't
Reach for View Transitions on navigational context changes: moving between pages, opening a detail from a list, expanding an inline panel. "You're going from here to there" maps onto the API naturally.
Don't reach for it on micro-interactions inside a view: hovers, spinners, a toggle's state change, a choreographed timeline. Those want CSS transition/animation or a real motion library where you control every keyframe.
The line: animation driven by navigation between views → View Transitions. Animation driven by interaction within a view → regular CSS or a library.
Back to that library you were about to install
The honest scope: View Transitions won't replace intricate choreography — that still needs more. What they replace is the default case. The clean cross-fade. The hero morph. The transition every multi-page app should have and usually skips, because nobody wanted to pull in Framer Motion for something that basic.
Takeaway for tomorrow: before you add an animation library for navigation, ask what one CSS rule already buys you. Often it's most of what you needed — and the rest is two matching names.
What's the page transition you abandoned because wiring it up wasn't worth it? Try the one-rule version on it tonight and tell me how close it got in the comments.
Top comments (1)
How does this new approach handle edge cases like interrupted transitions or dynamically loaded content? I'd love to swap ideas on this, following for more insights.