View Transitions in Practice: Native Page Transitions for Multi-Page Sites

For most of the last decade, smooth page transitions were a privilege reserved for single-page applications. If you shipped a classic multi-page site — server-rendered HTML, plain links, full navigations — your users got an abrupt white flash between pages, no matter how polished the rest of the design was. The usual fix was to rewrite the frontend as an SPA, or bolt on a transition library that intercepted every link and swapped content manually. Both options carry real costs: bundle size, hydration complexity, SEO considerations, and a whole class of bugs that exist only because you are now managing navigation in JavaScript.

The View Transitions API flips that trade-off. It gives the browser the job of capturing before-and-after states and animating between them, while your markup stays ordinary HTML. In this post we’ll walk through the same-document API, the cross-document at-rule that makes multi-page transitions work with a few lines of CSS, named element morphing, and a production-ready progressive enhancement strategy.

The Core Idea: The Browser Owns the Animation

The mental model is simple. When a view transition starts, the browser takes a live screenshot of the old state, applies your DOM change, takes a snapshot of the new state, and then runs a CSS animation between the two. Each snapshot becomes a pseudo-element tree you can style: ::view-transition-old(root) is the outgoing state, ::view-transition-new(root) is the incoming one, and a group pseudo-element wraps each pair.

For same-document transitions — a tab switch, a filter panel collapsing, a list reordering — you wrap your DOM mutation in document.startViewTransition() and you’re done:

function switchTab(nextTab) {
  document.startViewTransition(() => {
    updateDOM(nextTab); // your existing render logic
  });
}

The callback runs after the old state is captured, and the transition completes when the new state has settled. If the browser is busy or the user prefers reduced motion, the whole thing degrades to an instant swap. That last part matters: the API is not an animation library bolted onto the DOM — it is a rendering pipeline feature, which is why it can snapshot an entire page without jank.

The default transition is a subtle cross-fade on the root element, and it already looks better than a hard cut. But the real power comes from styling the transition pseudo-elements with CSS:

::view-transition-old(root) {
  animation: fade-out 200ms ease-out;
}

::view-transition-new(root) {
  animation: fade-in 200ms ease-in;
}

@keyframes fade-out {
  to { opacity: 0; }
}

@keyframes fade-in {
  from { opacity: 0; }
}

Cross-Document Transitions: The MPA Game Changer

Same-document transitions are useful, but the feature that changes the economics for multi-page sites is the cross-document variant, defined in the CSS View Transitions Level 2 spec. Instead of calling any JavaScript, you opt in with an at-rule:

@view-transition {
  navigation: auto;
}

Include that rule in both the outgoing and incoming page (putting it in your shared stylesheet does exactly that), and every same-origin navigation gets a smooth cross-fade automatically. The browser intercepts the navigation, snapshots the old page, fetches and renders the new one, and animates between the snapshots. No service worker, no link interception, no content swapping. Your pages remain fully cacheable, linkable, server-rendered documents.

One older pattern you should stop copying: the meta tag approach, <meta name="view-transition" content="same-origin">, came from an early Chrome proposal and is deprecated. The at-rule is the standard, and it’s what ships across browsers.

You can go beyond the default fade by assigning transition types and styling them conditionally. A classic use is directional sliding — forward navigations slide left, back navigations slide right — using the pageswap and pagereveal events to set a transition type, then keying animations off it with the :active-view-transition-type() pseudo-class:

// On the outgoing page
window.addEventListener("pageswap", (event) => {
  if (!event.viewTransition) return;
  const isBack = event.activation?.navigationType === "traverse";
  event.viewTransition.types.add(isBack ? "backwards" : "forwards");
});

// On the incoming page
window.addEventListener("pagereveal", (event) => {
  if (!event.viewTransition) return;
  const isBack = navigation.activation?.navigationType === "traverse";
  event.viewTransition.types.add(isBack ? "backwards" : "forwards");
});
html:active-view-transition-type(forwards)::view-transition-new(root) {
  animation-name: slide-from-right;
}

html:active-view-transition-type(backwards)::view-transition-new(root) {
  animation-name: slide-from-left;
}

Morphing Elements Between Pages

The root-level crossfade is the appetizer. The signature effect of the API is element continuity: a product card in a grid appearing to expand into the product detail page. This works with the view-transition-name property. Give the same name to an element on both pages, and the browser pairs them up — instead of fading the whole root, it animates that element’s position and size independently:

/* Listing page */
.product-card-42 {
  view-transition-name: product-42;
}

/* Detail page */
.product-hero {
  view-transition-name: product-42;
}

During the transition, the browser generates a dedicated group for product-42 and interpolates its geometry. The rest of the page cross-fades as usual. The result is the kind of shared-element transition people used to associate with native mobile apps — on a server-rendered site with zero JavaScript.

Two constraints to know. First, transition names must be unique per snapshot: only one element with a given name can be visible at transition time, which is why listing pages typically set the name dynamically on the tapped element. Second, name pairing happens at snapshot time, so both pages need the name present when the snapshots are taken — for cross-document transitions, that means rendering it server-side or reading it from the navigation event.

When many elements share styling for transitions — say, every product card — styling each pseudo-element by name gets tedious. The view-transition-class property solves this: assign a class to many named elements, then style the whole class’s pseudo-elements at once:

.product-card {
  view-transition-name: card-42; /* must still be unique */
  view-transition-class: product-card;
}

::view-transition-group(.product-card) {
  animation-duration: 300ms;
}

Browser Support and Progressive Enhancement

Support reached a meaningful milestone recently. Same-document transitions are Baseline since 2025: Chrome has had them since version 111, Safari since 18, and Firefox shipped them in version 144. Cross-document transitions are newer and narrower: Chrome since 126 and Safari since 18.2, while Firefox has not shipped them yet at the time of writing. Element-scoped transitions — scoping a transition to a subtree rather than the whole document — landed in Chrome 147 in March 2026.

This split is exactly why the API was designed for graceful degradation. The @view-transition rule is inert where unsupported; document.startViewTransition simply doesn’t exist on older browsers. A defensive check keeps your JavaScript honest:

if (!document.startViewTransition) {
  updateDOM(nextTab);
  return;
}
document.startViewTransition(() => updateDOM(nextTab));

The practical strategy: treat transitions as enhancement and make sure the underlying interaction works instantly without them. Users on Firefox get hard cuts for cross-page navigations today and will start getting transitions automatically as support lands — no code changes required.

Respect user preferences too. Motion sensitivity is an accessibility issue, and the pseudo-element tree is ordinary CSS, so a media query is all it takes:

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

Production Pitfalls

A few things bite people in real deployments:

  • Snapshot cost scales with page size. The root snapshot is essentially a full-page texture. On very long pages, transitions can get memory-heavy on mobile. Consider shorter durations and avoiding complex filters on the root group.
  • Rapid interactions need care. If a user clicks a tab twice quickly, you can get overlapping transitions. The API skips the old transition when a new one starts, but mutations from an unfinished callback can race your app state. Guard with a simple in-flight flag if your update logic is slow.
  • Live regions and video don’t snapshot well. The snapshot is static. Media that must keep playing through a transition is better kept out of the animated subtree — this is what element-scoped transitions in newer Chrome releases address.
  • Cross-document transitions need same-origin. Navigation to another origin skips the transition silently. That’s a privacy boundary, not a bug.

Where This Leaves Us

View Transitions remove the strongest remaining argument for reaching for an SPA framework purely to get polished navigation. A server-rendered site with one at-rule now ships the cross-fade; a dozen lines of CSS add directional slides and shared-element morphs. The Chrome developer documentation has a thorough guide to the full pseudo-element model, and the MDN reference covers the same-document API in depth.

If you maintain a multi-page site, start with the at-rule in your shared stylesheet, add reduced-motion handling, and then selectively name the elements that deserve continuity — a card, a thumbnail, an avatar. Measure on a mid-range phone, keep durations under about 300 milliseconds, and you’ll get a native-app feel without shipping a single line of navigation JavaScript.

Leave a Reply

Your email address will not be published. Required fields are marked *