Animating Navigations with the View Transitions API multi-page Pattern
For years, smooth animated page changes were the exclusive reward of single-page applications, and you paid for them with a router, a hydration budget, and a pile of state. The View Transitions API multi-page approach finally breaks that trade-off: a classic server-rendered site, where each click is a real document navigation, can now morph between pages with the same polish a SPA spends megabytes to achieve.
Moreover, the mechanism is largely declarative. You opt in with a few lines of CSS, name the elements that should persist across the navigation, and the browser snapshots the old and new states and tweens between them. Consequently, the question shifts from “how do I build this” to “how do I build this responsibly” — with fallbacks, accessibility, and predictable naming.
Same-Document vs Cross-Document Transitions
First, it helps to separate two flavors of the feature. The same-document variant, driven by document.startViewTransition(), animates changes inside one page — for example, when a client router swaps content without a full reload. The cross-document variant, by contrast, animates a real navigation between two HTML documents, which is exactly what multi-page apps need.
Because the cross-document path requires no JavaScript callback, it is the cleaner story for server-rendered sites. The browser intercepts a same-origin navigation, captures the outgoing page, loads the incoming page, and plays the transition automatically. According to the MDN documentation, both pages must opt in for the cross-document transition to fire.
/* Opt both the old and new documents into cross-document transitions.
Place this in a stylesheet linked from every page that participates. */
@view-transition {
navigation: auto;
}
/* The browser generates a default crossfade for the full page.
You can tune its timing through the root pseudo-elements. */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 250ms;
animation-timing-function: ease;
}
Naming Shared Elements with view-transition-name
A plain crossfade is pleasant, but the real magic is the shared-element morph: a thumbnail on a list page that grows into the hero image on a detail page. To wire this up, give the matching elements on both pages the same view-transition-name. The browser then animates position, size, and shape between the two snapshots instead of fading them.
However, there is a hard rule: each view-transition-name must be unique within a single document. Therefore you cannot give every card in a grid the same name; you have to scope the shared name to the one element that actually transitions. Generating the name from a stable identifier is the usual fix.
/* List page: only the clicked card's image carries the shared name.
A server template or a small script assigns it per item id. */
.product-card[data-active] .product-image {
view-transition-name: product-hero;
}
/* Detail page: the hero image claims the same name,
so the browser morphs one into the other across the navigation. */
.product-detail .hero-image {
view-transition-name: product-hero;
contain: layout; /* keep the element a clean transition root */
}
/* Persist site chrome so the header does not flicker mid-navigation. */
.site-header {
view-transition-name: site-header;
}
Notice that the header gets its own name too. As a result, the navigation bar stays rooted while the content underneath animates, which reads as a far more intentional transition. For deeper routing patterns that pair well with this technique, see our guide to type-safe React routing.
Customizing the ::view-transition Pseudo-Elements
During a transition, the browser builds a tree of pseudo-elements over the page. At the top sits ::view-transition, and beneath it each named element gets a group containing an ::view-transition-old() and ::view-transition-new() image. Because these are ordinary pseudo-elements, you style them with ordinary CSS — including custom keyframes.
/* A directional slide for the main content,
while named elements keep their own morph animations. */
@keyframes slide-from-right {
from { transform: translateX(40px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-to-left {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-40px); opacity: 0; }
}
::view-transition-old(main-content) {
animation: 220ms ease both slide-to-left;
}
::view-transition-new(main-content) {
animation: 220ms ease both slide-from-right;
}
Additionally, you can react to where the user is heading. The pageswap and pagereveal events fire on the outgoing and incoming documents respectively, exposing the active ViewTransition object so you can set types or read the navigation. That hook lets you choose forward versus backward animations.
// Tag the transition with a "type" so CSS can branch on direction.
// pageswap fires on the page you are leaving.
window.addEventListener('pageswap', (event: PageSwapEvent) => {
const navigation = event.viewTransition;
if (!navigation) return;
const from = new URL(event.activation?.from?.url ?? location.href);
const to = new URL(event.activation?.entry?.url ?? location.href);
const goingDeeper = to.pathname.length > from.pathname.length;
navigation.types.add(goingDeeper ? 'forward' : 'backward');
});
// pagereveal fires on the page you are entering, before first paint.
window.addEventListener('pagereveal', (event: PageRevealEvent) => {
if (!event.viewTransition) return;
// You can adjust view-transition-name assignments here if needed,
// for example matching the element the user just clicked.
document.documentElement.dataset.navReady = 'true';
});
Accessibility and prefers-reduced-motion
Motion is not free for everyone. Some users experience nausea or disorientation from large sliding transitions, so respecting the operating-system preference is non-negotiable. Fortunately, because transitions are pure CSS animations, you disable them with the same media query you already use elsewhere.
/* Honor the user's stated preference: drop the motion,
keep an instant (or barely-there) crossfade. */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
Beyond reduced motion, keep durations short — roughly 200 to 300 milliseconds is a common comfortable range — and avoid animating across the entire viewport when a localized morph would do. The spec and web.dev guidance both stress that transitions should clarify a relationship between states, not decorate the navigation for its own sake.
Progressive Enhancement, Support, and Fallbacks
Critically, this feature is built for graceful degradation. In a browser that does not understand @view-transition, the at-rule is simply ignored, the view-transition-name declarations do nothing, and the navigation happens exactly as it always did — an instant page load. No polyfill, no broken layout, no error. That property makes adoption nearly risk-free.
As of 2026, Chromium-based browsers ship cross-document transitions, and other engines are progressing through implementation. Because the fallback is a normal navigation, teams can ship the enhancement today and let the experience improve automatically as support widens. This is progressive enhancement in its textbook form, much like the layered approach we describe for offline-capable PWAs.
Common Pitfalls to Avoid
Nevertheless, a few sharp edges trip up newcomers. The most frequent is duplicate view-transition-name values, which aborts the transition silently; always scope shared names to one element per page. Likewise, layout shift between the old and new snapshots produces jarring jumps, so reserve space for images with width and height attributes.
Another subtlety: transitions only run for same-origin navigations, and the incoming document must also opt in, or nothing animates. Finally, very large snapshots (full-page screenshots of long documents) can cost frames, so prefer naming a handful of meaningful elements over animating everything. When you suspect a slow transition, the same profiling habits from general hybrid-rendering performance work apply here.
When Not to Use It
Despite the appeal, transitions are not always the right call. Skip them on data-dense dashboards where users navigate rapidly and motion becomes noise, and reconsider them on flows where speed perception matters more than polish — a 250-millisecond animation is, after all, 250 milliseconds the user waits to read. In those cases an instant swap respects the user’s time better.
In conclusion, the View Transitions API multi-page pattern delivers genuinely app-like navigation to ordinary server-rendered sites with a CSS-first, progressively enhanced contract. Start with a global crossfade, add one or two shared-element morphs where they clarify a relationship, gate everything behind prefers-reduced-motion, and trust the silent fallback. The result is motion that feels intentional rather than ornamental — and you shipped it without a single-page-app framework in sight.