EdgeCases Logo
Feb 2026
Browser APIs
Deep
8 min read

View Transitions API: Cross-Document Navigation Edge Cases

Cross-document View Transitions enable native MPA animations, but 4-second timeouts, BFCache interactions, and name conflicts can break the magic.

view-transitions
mpa
cross-document
browser-apis
navigation
animation
edge-case

The View Transitions API brings native page-to-page animations to MPAs. Opt in with CSS, and the browser handles snapshots and crossfades. Except when navigations time out, the old page lingers too long, or your transition fires on the wrong navigation.

The Opt-In: Both Pages Must Agree

Cross-document view transitions require explicit opt-in on both the old and new page:

/* Required on BOTH pages */
@view-transition {
  navigation: auto;
}

If either page lacks this rule, no transition occurs. This is intentional—it prevents transitions during external navigations or to pages that aren't designed for them.

Same-Origin Only

Cross-document transitions only work for same-origin navigations. Scheme, hostname, and port must all match:

// ✅ Same origin - transition works
https://example.com → https://example.com/page

// ❌ Different subdomain - no transition
https://example.com → https://www.example.com

// ❌ Different port - no transition
https://example.com:3000 → https://example.com:3001

The 4-Second Timeout

Chrome skips the view transition if navigation takes longer than 4 seconds. The browser throws a TimeoutError DOMException and navigates without animation.

window.addEventListener('pagereveal', (e) => {
  if (e.viewTransition) {
    e.viewTransition.ready.catch((err) => {
      if (err.name === 'TimeoutError') {
        // Navigation was too slow
        console.log('View transition timed out');
      }
    });
  }
});

For slow pages, consider prerendering or prefetching to beat the timeout.

pageswap and pagereveal: The Customization Points

Two events let you customize transitions:

  • pageswap: Fires on the old page before it's hidden. Last chance to modify elements for snapshot.
  • pagereveal: Fires on the new page before first render. Customize before new snapshot is taken.
// Old page: Set up the outgoing snapshot
window.addEventListener('pageswap', (e) => {
  if (e.viewTransition) {
    const clickedCard = document.querySelector('.clicked');
    clickedCard.style.viewTransitionName = 'hero-card';
  }
});

// New page: Set up the incoming snapshot
window.addEventListener('pagereveal', (e) => {
  if (e.viewTransition) {
    const heroCard = document.querySelector('.hero');
    heroCard.style.viewTransitionName = 'hero-card';
  }
});

The ViewTransition Objects Are Different

e.viewTransition in pageswap and pagereveal are separate objects. They handle promises differently:

// pageswap (old page)
window.addEventListener('pageswap', async (e) => {
  if (e.viewTransition) {
    await e.viewTransition.finished;
    // ⚠️ Document is already hidden at this point
    // Resolve happens just before unload
  }
});

// pagereveal (new page)
window.addEventListener('pagereveal', async (e) => {
  if (e.viewTransition) {
    await e.viewTransition.ready;
    // Snapshots taken, animation about to start
    await e.viewTransition.finished;
    // Animation complete
  }
});

NavigationActivation: Know Where You Came From

Customize transitions based on navigation direction using navigation.activation:

window.addEventListener('pagereveal', (e) => {
  if (!e.viewTransition) return;

  const from = new URL(navigation.activation.from.url);
  const to = new URL(navigation.activation.entry.url);

  if (isListPage(from) && isDetailPage(to)) {
    // List → Detail: slide in from right
    e.viewTransition.types.add('slide-forward');
  } else if (isDetailPage(from) && isListPage(to)) {
    // Detail → List: slide in from left
    e.viewTransition.types.add('slide-back');
  }
});

Then target these types in CSS:

::view-transition-old(root).slide-forward {
  animation: slide-out-left 0.3s ease-out;
}

::view-transition-new(root).slide-forward {
  animation: slide-in-right 0.3s ease-out;
}

view-transition-name Conflicts

Each view-transition-name must be unique on the page. Duplicates cause the transition to skip those elements:

/* ❌ Multiple elements with same name */
.card {
  view-transition-name: card; /* All cards share name */
}

/* ✅ Unique names per element */
.card:nth-child(1) { view-transition-name: card-1; }
.card:nth-child(2) { view-transition-name: card-2; }

For dynamic lists, set view-transition-name via JavaScript in pageswap / pagereveal:

window.addEventListener('pageswap', async (e) => {
  if (e.viewTransition) {
    const profile = getClickedProfile();
    document.querySelector(`#${profile} img`)
      .style.viewTransitionName = 'avatar';

    // Clean up after transition
    await e.viewTransition.finished;
    document.querySelector(`#${profile} img`)
      .style.viewTransitionName = '';
  }
});

BFCache and History Traversal

Back-forward cache (BFCache) restores pages from memory during history traversal. This interacts with view transitions in surprising ways:

  • pagereveal fires for BFCache restores
  • Your JavaScript state persists—including dynamically set view-transition-name
  • If you don't clean up, names might still be set from the previous transition
// Utility to temporarily set view-transition-names
async function withTransitionNames(entries, promise) {
  for (const [el, name] of entries) {
    el.style.viewTransitionName = name;
  }
  await promise;
  for (const [el] of entries) {
    el.style.viewTransitionName = '';
  }
}

Render Blocking: Wait for Critical Content

You can delay first paint until specific elements exist using render blocking:

<head>
  <link rel="expect" blocking="render" href="#hero-image">
</head>

The page won't render until #hero-image is in the DOM. This ensures your transition animates to stable content, not a loading state.

Caveat: Blocks Presence, Not Loading

blocking="render" waits for the element to exist in the DOM—not for images to load or async content to hydrate:

<!-- Render unblocks when <img> tag exists -->
<img id="hero-image" src="/hero.jpg">
<!-- Image might still be loading when transition starts -->

For images, consider using decoding="sync" or waiting in JavaScript.

Skipping Transitions Conditionally

Sometimes you don't want a transition—skip it programmatically:

window.addEventListener('pagereveal', (e) => {
  if (e.viewTransition) {
    if (shouldSkipTransition()) {
      e.viewTransition.skipTransition();
    }
  }
});

Common skip conditions:

  • User has prefers-reduced-motion
  • Navigation is "lateral" (same hierarchy level)
  • Device is low-powered or slow connection
function shouldSkipTransition() {
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches
    || navigator.connection?.saveData
    || navigator.hardwareConcurrency < 4;
}

Browser Support: Chrome-First, Firefox TBD

Cross-document view transitions shipped in Chrome 126 (June 2024). As of early 2026:

  • Chrome/Edge 126+: Full support
  • Safari: Same-document only (no cross-document MPA support yet)
  • Firefox: In development, behind flag

Progressive enhancement is key—transitions enhance experience but navigation works without them:

@supports (view-transition-name: test) {
  @view-transition {
    navigation: auto;
  }
}

Debugging Tips

  • Chrome DevTools → Animations panel shows view transition pseudo-elements
  • Slow down animations: Animations panel has a playback speed control
  • Check for duplicate view-transition-name in Elements panel
  • Console errors show TimeoutError or InvalidStateError
  • Use e.viewTransition.ready.catch() to log transition failures

Key Takeaways

  • Both pages must opt in with @view-transition { navigation: auto; }
  • Chrome times out transitions after 4 seconds—prerender slow pages
  • pageswap and pagereveal have separate ViewTransition objects
  • view-transition-name must be unique; set dynamically for lists
  • Clean up transition names to handle BFCache correctly
  • Use navigation.activation to customize based on source/destination
  • Progressive enhancement—transitions should fail gracefully

Advertisement

Related Insights

Explore related edge cases and patterns

Browser APIs
Surface
HTML Popover API: The Hidden Gotchas
6 min
React
Deep
React Suspense Error Boundaries: Recovery Patterns
7 min

Advertisement