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.
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:3001The 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:
pagerevealfires 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-namein Elements panel - Console errors show
TimeoutErrororInvalidStateError - 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
pageswapandpagerevealhave separate ViewTransition objectsview-transition-namemust be unique; set dynamically for lists- Clean up transition names to handle BFCache correctly
- Use
navigation.activationto customize based on source/destination - Progressive enhancement—transitions should fail gracefully
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement