All WordPress HTML Templates Forms & Webhooks AI & Tools
HTML Templates

The View Transitions API for Multi-Page Sites

A practitioner’s guide to the View Transitions API for multi-page sites: cross-document setup, duplicate name errors, pageswap types and what breaks live.

A real working moment illustrating the theme of an article about view transitions api. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

Eleven lines of CSS. That’s what it took to add cross-page animation to a client’s documentation site last year, and the first version shipped without a single byte of JavaScript. The second version broke within an hour of going live, because the blog index had twelve cards all claiming the same view-transition-name, and Chrome does exactly what the spec says in that situation: it skips the whole transition and logs an error.

That gap between the demo and the production site is what this post is about. The View Transitions API is genuinely good now, cross-document support is in two of the three engines, and it has removed what used to be the only honest argument for turning a content site into a single-page app. It also has sharp edges that no listicle mentions, starting with the fact that you’re now holding the old page on screen while you wait for the server.

Key Takeaways

  • Cross-document transitions need @view-transition { navigation: auto; } in the CSS of both the outgoing and incoming document, same origin only, and they are inert on browsers that don’t support them. Zero risk fallback.
  • Duplicate view-transition-name values abort the entire transition. Generating unique, per-item names from the slug in your template solves the list-to-detail morph with no JavaScript at all.
  • The browser pauses on the old page until the new document is ready to render, so a 600ms TTFB now feels like a 600ms freeze. Pair transitions with speculation rules prefetch and <link rel="expect" blocking="render">.
  • Use pageswap and pagereveal plus view transition types for direction-aware animation. History entry indices tell you back from forward; navigationType alone does not.
  • Keep named elements small and bounded. Snapshots are GPU textures and a named full-height article wrapper can exceed the common 16384px texture limit and silently drop.

Why multi-page sites are the real use case

For about a decade, “we need page transitions” was a legitimate reason to reach for a client-side router. You couldn’t animate between two documents, so people simulated documents: hijack the click, fetch the HTML, swap the DOM, animate the swap, patch scroll restoration, patch focus management, patch analytics, patch the back button. We’ve inherited plenty of those codebases. The transition is usually the only part that still works.

Cross-document view transitions delete that entire category of work. The browser keeps ownership of navigation, history, scroll restoration, focus and the bfcache. You describe the animation in CSS. If you’re adding a SPA router in 2026 purely so pages can fade, that’s the wrong call, and it’s a trade you’ll pay for in accessibility bugs for years.

One hard boundary: this only applies to same-origin navigations. Cross-origin links, cross-origin redirects mid-navigation and anything the browser treats as a fresh browsing context will not transition. Neither will a navigation where the new document never opts in, which is the single most common support ticket.

A real working moment that fits an article section about

The minimum viable view transitions API setup

Both documents must opt in. The outgoing page’s declaration arms the snapshot; the incoming page’s declaration accepts it. Miss one and you get a hard cut with no error message.

/ Must be present in the CSS of every page that participates /
@view-transition {
  navigation: auto;
}

/* Default root cross-fade runs 250ms. Slightly longer with an ease-out
   curve reads less abrupt on text-heavy pages. */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 320ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

@media (prefers-reduced-motion: reduce) {
  /* Kill the movement, keep a short fade. Opacity changes rarely trigger
     vestibular symptoms; translation and scale do. */
  ::view-transition-group(*) { animation-duration: 1ms; }
  ::view-transition-old(root),
  ::view-transition-new(root) { animation-duration: 120ms; }
}

Fully disabling the transition under reduced motion is a defensible alternative, but we stopped doing it. A hard cut with no fade makes it harder to tell that the page changed at all, which is a different accessibility problem. A 120ms fade is honest about the navigation without moving anything.

If your CSS is split per template, put the @view-transition rule in the global stylesheet, not the page-specific one. We’ve debugged “transitions work on the blog but not from the blog” twice now, and both times it was a critical-CSS split that dropped the at-rule on one route.

Naming elements without blowing up the transition

The default root cross-fade is fine. The thing people actually want is the thumbnail on the index morphing into the hero on the detail page. That requires a view-transition-name that matches across both documents, and it requires that name to be unique within each document at capture time.

Most tutorials solve this with JavaScript: listen for the click, stamp a name on the element you clicked, stamp the same name on the destination hero. It works, but it’s unnecessary. Generate unique names from the slug in your template and the matching happens for free:

<a href="<?= escurl( getpermalink() ) ?>" class="card">
  <img
    src="<?= esc_url( $thumb ) ?>"
    alt=""
    <?php /* Prefix matters: a slug like 2026-roadmap is not a valid
             custom-ident, so "thumb-2026-roadmap" is the safe form. */ ?>
    style="view-transition-name: thumb-<?= escattr( $post->postname ) ?>">
  <h3><?= eschtml( getthe_title() ) ?></h3>
</a>

On the index there are twelve unique names. On the detail page there’s one. Only the pair that matches animates as a group; everything else falls back to the root cross-fade. No JavaScript, works with the back button, works from a bfcache restore.

Two caveats. First, an element with view-transition-name becomes a stacking and containing block, so absolutely positioned children can shift. Check the layout before and after. Second, naming an element that fragments across columns or pages skips the transition entirely, so don’t name anything inside a multi-column layout.

view-transition-class for shared styling

Once you have fifty generated names, styling them individually is miserable. view-transition-class lets you target them as a set:

.card img { view-transition-class: thumb; }

::view-transition-group(.thumb) {
  animation-duration: 420ms;
  animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

Direction-aware transitions with pageswap and pagereveal

Forward navigation sliding left and back navigation sliding right is the one piece of polish that makes transitions feel native rather than decorative. You need both documents to agree on the direction, which is why there are two events: pageswap fires on the outgoing document just before the snapshot, pagereveal fires on the incoming document before its first render.

Don’t rely on navigationType to tell back from forward. It reports traverse for both. Compare history entry indices instead:

function direction(activation) {
  const from = activation.from;
  const entry = activation.entry;
  // Lower destination index means we moved backwards through history.
  if (from && entry && entry.index < from.index) return 'nav-back';
  return 'nav-forward';
}

window.addEventListener('pageswap', (event) => {
  if (!event.viewTransition) return;              // no transition this time
  event.viewTransition.types.add(direction(event.activation));
});

window.addEventListener('pagereveal', (event) => {
  if (!event.viewTransition) return;
  // The incoming document gets its own types; they are not inherited.
  event.viewTransition.types.add(direction(navigation.activation));
});
html:active-view-transition-type(nav-forward) ::view-transition-old(root) {
  animation: 300ms both slide-out-start;
}
html:active-view-transition-type(nav-back) ::view-transition-old(root) {
  animation: 300ms both slide-out-end;
}

@keyframes slide-out-start {
  to { transform: translateX(-30%); opacity: 0; }
}
@keyframes slide-out-end {
  to { transform: translateX(30%); opacity: 0; }
}

Those keyframes are physically directional, which means they’re wrong in Arabic and Hebrew. Flip them with a :dir(rtl) override or build the whole thing on logical values where you can. If you’re not already thinking in logical properties, our writeup on what breaks in RTL and how logical properties fix it covers the same class of bug across layout.

For static per-page types you can skip the JS entirely. Put @view-transition { navigation: auto; types: product; } in a template-specific stylesheet and you can differentiate a product page transition from a blog one with no script at all.

The trade-off: you’re now waiting on the server

Here’s the part that gets left out. In a same-document transition, the new state is already in the DOM, so the animation starts immediately. Cross-document, the browser has to hold the old page on screen until the new document reaches its first render. On a fast static host that’s 80ms and feels instant. On a cold WordPress response at 700ms, the user clicks and nothing happens for most of a second. Because the old page is still fully painted, it reads as a broken click rather than a loading page.

Transitions make slow servers feel worse, not better. Fix the latency first.

Two mechanics help. Speculation rules prefetch gets the document into memory before the click:

<script type="speculationrules">
{
  "prefetch": [{
    "where": { "href_matches": "/blog/*" },
    "eagerness": "moderate"
  }]
}
</script>

We stick to prefetch rather than prerender for pages that transition. Prerender activation and view transitions have had an awkward relationship in Chrome, and the behaviour has shifted between releases, so test the combination on your own stack before you ship it.

The second mechanic is render blocking. Without it, the new document can reach first render with only the header parsed, so your hero snapshot captures a half-built page and the morph lands on empty space:

<!-- Hold first render until #main exists, so the incoming
     snapshot captures the real hero rather than a skeleton. -->
<link rel="expect" href="#main" blocking="render">

Font loading interacts here too. If your hero text swaps typeface mid-transition you get a visible reflow inside the animation, which looks far worse than it does on a normal load. Our font loading strategy post applies directly: preload the one face used in the transitioning region and keep size-adjust tuned.

What actually breaks in production

Sticky headers. Unnamed, a fixed header is baked into the root snapshot and cross-fades against itself, which looks like a double-exposure. Give it view-transition-name: site-header and it becomes its own group that stays put. This is the first thing we change on any Bootstrap-based layout, including the sticky header patterns in the Canvas template demos.

Oversized snapshots. Named elements are captured at full ink overflow, not viewport size. Name a full-length article wrapper on a long post and you ask the compositor for a texture that can exceed the 16384px limit common on desktop GPUs. The symptom is a blank or clipped group, often only on one machine. Keep names on bounded elements: images, cards, headings, the header.

Scroll position. The outgoing snapshot is taken at the current scroll offset while the incoming document renders at its own. Navigate from halfway down a long index to a short page and the morph can travel further than you expect. Fragment navigations (#section links) are worth testing specifically.

Injected markup. On WordPress, the admin bar, cookie banners and anything a plugin appends to body all join the snapshot. Logged-in editors see a different animation from visitors. Exclude them or test as both.

Nested scrollers. A scrolled inner container reverts to the top in the new document’s snapshot. There’s no clean fix; avoid naming anything inside a scroll container.

Support, and why the fallback costs you nothing

Chrome and Edge have shipped cross-document transitions since Chrome 126 in mid-2024. Safari added them in 18.2 at the end of that year. Firefox shipped same-document transitions in 144 and cross-document support has been trailing behind it, so check the current state rather than assuming parity across all three engines.

None of that matters much, because the degradation is perfect. A browser that doesn’t understand @view-transition ignores an unknown at-rule, ignores the ::view-transition-* pseudo-elements, and performs a completely normal navigation. pageswap never fires, your handler never runs, nothing throws. This is textbook progressive enhancement: the enhanced path is a visual nicety and the base path is the thing that actually works.

Frequently Asked Questions

Do I need JavaScript for cross document view transitions?

No. A root cross-fade needs one CSS at-rule, and shared element morphs work with no script at all if you generate unique view-transition-name values in your templates. JavaScript only becomes necessary for direction-aware animation, where you need pageswap and pagereveal to agree on whether the user went forwards or backwards.

Why does my transition work one way but not the other?

Almost always because one of the two documents is missing the @view-transition { navigation: auto; } rule. Both the page you leave and the page you arrive at must opt in. Check for per-route critical CSS or a template that loads a different stylesheet.

Can I use this with Astro, Nuxt or SvelteKit?

Yes, and for content sites the native cross-document path is usually better than the framework’s SPA-mode router. Astro’s ClientRouter, for example, exists to emulate this for older browsers; if your analytics show your audience is overwhelmingly on Chrome and Safari, dropping the client router removes JavaScript and hands scroll and history handling back to the browser.

Will page transitions hurt my Core Web Vitals?

The transition animations run off the main thread and don’t count against LCP directly, but render blocking via rel="expect" genuinely does delay first paint, so measure it. The bigger risk is perceptual: holding the old page while the server responds makes slow TTFB far more noticeable. Profile a cold response, not a warm one.

How do I handle duplicate view-transition-name errors?

Make every name unique within the document. Derive it from a post ID or slug, and always prefix it so you don’t produce an invalid custom-ident from a slug that starts with a digit. If you cannot make names unique in markup, assign the name in a pageswap handler to the single element the user interacted with and let everything else fall back to the root transition.

If you run a content or commerce site on real server-rendered HTML, this is a half-day change with an unusually good payoff: add the at-rule, name your header, generate per-item names on your index templates, then measure your TTFB on the routes people actually click. If that number is above roughly 300ms, go fix it before you animate anything. The transition will only make the wait more visible.

at view-transition navigation auto cross document view transitions pageswap and pagereveal events smooth page transitions css speculation rules prefetch view transitions view transition types active-view-transition-type view transitions api view-transition-name duplicate error