Cross-Document View Transitions: A Frontend Guide

Cross-Document View Transitions: A Frontend Guide

K
Kodetra Technologies·April 27, 2026·7 min read Intermediate

Summary

Add native hardware-accelerated transitions between full pages with pure CSS.

Why Cross-Document View Transitions matter in 2026

The View Transitions API graduated from a Chrome-only experiment to a stable, cross-browser feature this year, and the most exciting part of the upgrade is cross-document support. You can now animate the transition between two completely different HTML pages with a few lines of CSS, no SPA shell, no framework router hacks, and no JavaScript animation library. The browser snapshots both pages, runs hardware-accelerated tweens between them, and lets users see motion that used to be the exclusive territory of native iOS and Android.

Real-world numbers make it worth your time: replacing JS-driven page-transition animations with the native pseudo-elements has been measured at ~30% better Total Blocking Time and can shave 50 KB+ off your animation bundle. Even better, every modern static site, multi-page Rails app, server-rendered Django app, or PHP storefront can opt in — you do not need a single-page architecture to get app-like transitions.

This guide walks you through the entire path: a default cross-fade, named shared-element morphs, navigation-direction-aware animations, framework integration for Astro, Next.js, and SvelteKit, plus the pitfalls that bite teams in production.

Prerequisites

  • A working multi-page site you can edit two HTML files on (any framework or vanilla)
  • Chrome 126+, Safari 18.2+, or Firefox 130+ (cross-document support is in all three as of April 2026)
  • Familiarity with CSS pseudo-elements and the animation shorthand
  • Optional: a tool to throttle CPU in DevTools so you can see the snapshot phase clearly

Step 1 — Opt in on both pages

Cross-document transitions are opt-in on both ends. The outgoing page must agree to be snapshotted and the incoming page must agree to receive the snapshot. You ask the browser by setting a single @view-transition at-rule:

/* shared.css — included on every page */
@view-transition {
  navigation: auto;
}

navigation: auto means: "trigger a view transition on any same-origin navigation that the user initiates, except form POSTs." If both the page you are leaving and the page you are entering have this rule, the browser does the rest.

Quick smoke test — create two pages and link between them:

<!-- index.html -->
<!doctype html>
<html>
<head>
  <link rel="stylesheet" href="shared.css">
  <title>Home</title>
</head>
<body style="background:#0b1020; color:#eaeaea">
  <h1>Home</h1>
  <a href="/about.html">About</a>
</body>
</html>
<!-- about.html -->
<!doctype html>
<html>
<head>
  <link rel="stylesheet" href="shared.css">
  <title>About</title>
</head>
<body style="background:#1b0b20; color:#eaeaea">
  <h1>About</h1>
  <a href="/index.html">Home</a>
</body>
</html>

Click the link. Even with no further CSS you get a default cross-fade between the two backgrounds and headings. That is your free baseline.

Step 2 — Customize the default transition

Behind the scenes the browser builds two pseudo-element trees during the transition: ::view-transition-old(root) for the page you are leaving and ::view-transition-new(root) for the page you are entering, both attached to a top-level ::view-transition wrapper. You target them like any other selector:

::view-transition-old(root) {
  animation: fade-zoom-out 280ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
::view-transition-new(root) {
  animation: fade-zoom-in 320ms cubic-bezier(0.4, 0, 0.2, 1) both;
}

@keyframes fade-zoom-out {
  to { opacity: 0; transform: scale(0.96); }
}
@keyframes fade-zoom-in {
  from { opacity: 0; transform: scale(1.04); }
}

Two things to notice. First, you write real CSS animations — not a special API — so you can reuse easings, durations, and design tokens from the rest of your system. Second, the snapshot is rasterized once, so animating transform and opacity stays on the GPU even on cheap phones.

Step 3 — Named transitions for shared elements

The real magic is morphing a single element across the navigation — a thumbnail expanding into a hero image, or a card growing into a full article header. You give both elements the same view-transition-name and the browser tweens position, size, and visual style automatically.

Imagine a blog index linking to an article. On the index:

<!-- list.html -->
<a href="/posts/view-transitions.html" class="card">
  <img src="/img/vt-hero.jpg" alt="" class="card-thumb">
  <h2 class="card-title">Cross-Document View Transitions</h2>
</a>

<style>
  .card-thumb { view-transition-name: post-hero; }
  .card-title { view-transition-name: post-title; }
</style>

And on the destination page:

<!-- posts/view-transitions.html -->
<article>
  <img src="/img/vt-hero.jpg" alt="" class="hero">
  <h1 class="title">Cross-Document View Transitions</h1>
  <p>...</p>
</article>

<style>
  .hero  { view-transition-name: post-hero; width:100%; height:60vh; object-fit:cover; }
  .title { view-transition-name: post-title; font-size:3rem; }
</style>

When the user clicks the card, the browser sees that post-hero exists on both pages, snapshots each, and tweens the thumbnail's position and dimensions to match the hero. The same happens for post-title. Everything else cross-fades through the default root pseudo. The result feels like a native transition out of a SwiftUI navigation stack.

Names must be unique per page. If two visible elements share view-transition-name: post-hero at the same moment the browser will skip the transition for that name and warn in the console. For lists, generate names from the row id (post-hero-42) and only set the name on the row that links to the destination.

Step 4 — Direction-aware animations

Forward navigation should usually feel different from going back. The browser exposes the navigation type through the navigation object plus a CSS selector you can match on:

/* Slide left when going deeper, slide right on Back */
html:active-view-transition-type(forward) {
  --vt-x-start: 24px;
}
html:active-view-transition-type(back) {
  --vt-x-start: -24px;
}

::view-transition-new(root) {
  animation: slide-in 320ms ease both;
}
@keyframes slide-in {
  from { transform: translateX(var(--vt-x-start, 0)); opacity: 0; }
}

To set the type, you intercept the navigation in JS once and tell the API which label applies. With cross-document transitions, this lives in a pageswap event so it runs on the outgoing page just before the snapshot:

// shared.js — runs on every page
window.addEventListener('pageswap', (event) => {
  const t = event.viewTransition;
  if (!t) return;
  const fromUrl = new URL(event.activation.from.url);
  const toUrl   = new URL(event.activation.entry.url);
  const dir = toUrl.pathname.length > fromUrl.pathname.length ? 'forward' : 'back';
  t.types.add(dir);
});

Browsers that do not support the API just skip the listener — pageswap fires nowhere else, so you do not need a feature check.

Step 5 — Framework integration

Most modern meta-frameworks ship a one-liner. Here is the canonical setup for the three most common stacks in 2026:

Astro

---
// src/layouts/Base.astro
import { ViewTransitions } from 'astro:transitions';
---
<html>
  <head>
    <ViewTransitions />
  </head>
  <body><slot /></body>
</html>

Astro implements client-side fallback for browsers without native cross-document support, so you opt in once and forget. Add transition:name="post-hero" to elements for shared morphs.

Next.js (App Router)

// app/layout.tsx
import { unstable_ViewTransition as ViewTransition } from 'next/navigation';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <ViewTransition>{children}</ViewTransition>
      </body>
    </html>
  );
}

Next.js 15.4 wraps client navigations in document.startViewTransition automatically. For full-document MPA-style transitions add the same @view-transition CSS rule to your global stylesheet.

SvelteKit

// src/hooks.client.js
import { onNavigate } from '$app/navigation';

onNavigate((navigation) => {
  if (!document.startViewTransition) return;
  return new Promise((resolve) => {
    document.startViewTransition(async () => {
      resolve();
      await navigation.complete;
    });
  });
});

SvelteKit gives you a tiny imperative hook that pairs cleanly with the API. The same approach works in Remix, Nuxt, and any router that exposes a navigation lifecycle.

Common pitfalls and how to dodge them

  • Duplicate view-transition-name. Two visible elements with the same name silently kill the morph. Always scope names per item.
  • Position: fixed elements. Sticky headers and toasts are snapshotted in their final layout position; if you want them to stay locked across the transition, give them their own view-transition-name so the browser tweens their box, not the root.
  • Reduced motion. Wrap your animations in @media (prefers-reduced-motion: no-preference) — the API does not auto-disable, and forced motion is an accessibility regression.
  • Layout shift during snapshot. If the destination page lazy-loads images that change height, the morph ends mid-air. Reserve space with aspect-ratio or width/height attributes.
  • Cross-origin navigations. Cross-document transitions only work same-origin. Linking to a different domain skips the transition silently.
  • Form POST navigations. By design, navigation: auto excludes them. If you need a transition after a POST/redirect, use the navigation: same-origin setting and trigger explicitly with document.startViewTransition after the response renders.

Quick reference

ConceptWhere it livesUse it for
@view-transitionCSS at-ruleOpt every page in to navigation transitions
::view-transition-old(name)CSS pseudoAnimate the outgoing snapshot
::view-transition-new(name)CSS pseudoAnimate the incoming snapshot
view-transition-nameCSS property on elementMark a shared element to morph
pageswap eventJS on outgoing pageTag the transition or cancel it
pagereveal eventJS on incoming pageCustomize the new snapshot before the run
html:active-view-transition-type()CSS pseudo-classDirection or context-aware styling

Next steps

  1. Pick one high-traffic navigation in your app (search to detail, list to article) and ship the named morph for that single pair.
  2. Add the pageswap direction tag and split forward / back animations.
  3. Audit any place you reach for a JS animation library on navigation and replace it with the native pseudo-element route.
  4. Measure: compare TBT and INP before and after on the affected pages with the Performance panel or RUM.
  5. Subscribe to the pagereveal event for advanced choreography — staggered grids, masked reveals, scroll-position awareness.

Bottom line. Cross-document view transitions turn the browser into a free, accessible, GPU-accelerated animation engine for any multi-page app. The only thing standing between your site and motion that feels native is a CSS at-rule and a few well-chosen view-transition-name declarations.

Comments

Subscribe to join the conversation...

Be the first to comment