View Transitions API: Cross-Document Animations in 2026
Cross-document View Transitions landed in Chrome 126 and Safari 18. Here's how to wire up real page-to-page animations without a SPA, plus what still doesn't work.
Why Cross-Document View Transitions Are a Big Deal
For years, smooth page-to-page animations were a SPA tax. You wanted a hero image that morphs into the next page's header? Congratulations, you just committed to React Router, client-side rendering, and a 200 kB JavaScript bundle. That was the deal. Multi-page apps — the kind built with Next.js page router, plain HTML, or any server-rendered framework — got a hard cut between pages, full stop.
That changed in 2024 when Chrome 126 shipped cross-document View Transitions, and Safari 18 followed a few months later with its own implementation. By early 2026, both engines have iterated enough that you can actually ship this in production for most audiences. Firefox is still behind, but the API degrades gracefully — browsers that don't support it just do a normal navigation, which is fine.
Honestly, the spec is one of the more elegantly designed browser APIs of the last decade. You opt in with two lines of CSS, you annotate elements you want to animate with view-transition-name, and the browser handles the screenshot-capture-and-morph sequence automatically. No JavaScript required for the basic case. That's wild.
Worth noting: this is a fundamentally different code path from the same-document View Transitions API that shipped in Chrome 111. Same-document transitions let you animate DOM changes within a single page — like a tab switch or an accordion opening. Cross-document transitions fire on actual full navigations between different HTML documents. The CSS hook is the same (@keyframes targeting ::view-transition-* pseudo-elements), but the setup is different and the gotchas are different.
Opt-In: The Two Lines That Start Everything
You activate cross-document view transitions with a single meta tag in your <head>, or equivalently a CSS rule in your stylesheet. The CSS approach is more flexible because you can scope it with media queries or layer it with other rules:
/* In your global stylesheet */
@view-transition {
navigation: auto;
}That's it for the opt-in. Both the source page and the destination page need this rule. If only one of them has it, the transition silently falls back to a normal navigation. This is easy to forget when you're adding a new page to an existing site — add the rule to your base layout template and you won't have to think about it again.
The navigation: auto value tells the browser to intercept same-origin navigations and wrap them in a transition. You can also use navigation: none to explicitly opt out a specific page even if your base template opts in, which is useful for things like error pages or auth redirects where you don't want an animation.
Quick aside: in 2026, the @view-transition at-rule is the canonical way to do this. Earlier drafts of the spec used a <meta name="view-transition" content="same-origin"> tag instead. You'll see both in old tutorials. Stick with the at-rule — it gives you more control and doesn't pollute your HTML.
Naming Elements and Building the Morph
Once the opt-in is in place, the default transition is a cross-fade between the old page and the new one. It looks okay. But the real power is shared-element transitions — where a specific element on page A appears to physically move and reshape into a corresponding element on page B. Think an article card thumbnail on a blog index that expands into the hero image on the article page.
You wire this up with view-transition-name. Give the same name to the element on both pages and the browser does the interpolation:
/* On the blog index page */
.article-card__thumbnail {
view-transition-name: article-hero;
contain: layout;
}
/* On the article detail page */
.article-hero {
view-transition-name: article-hero;
contain: layout;
}The contain: layout declaration is required alongside view-transition-name. Without it, Chrome throws a warning and the transition may not capture correctly. This trips people up because it's not mentioned in many tutorials. Also — and this is critical — `view-transition-name` values must be unique per page. If two elements on the same page share a name, both transitions break silently. No error, just no animation. Fun to debug.
For dynamic lists — blog posts, product cards, search results — you can't hardcode unique names in CSS. You need to set them inline via JavaScript or via server-rendered style attributes. In a Next.js app router project running in 2026 you'd typically do this server-side:
``tsx
// app/blog/page.tsx
export default async function BlogIndex() {
const posts = await getPosts();
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>
<a href={/blog/${post.slug}}>
<img
src={post.image}
alt={post.title}
style={{ viewTransitionName: post-img-${post.slug} }}
/>
</a>
</li>
))}
</ul>
);
}
`
The destination app/blog/[slug]/page.tsx` sets the same name on its hero image and the browser connects the dots.
Customising the Animation with CSS
By default, the browser animates captured elements using a 250 ms cross-fade. You probably want something more interesting. The View Transitions API exposes a set of pseudo-elements you target with @keyframes to override the timing, easing, and motion:
/* Target the outgoing snapshot of the named element */
::view-transition-old(article-hero) {
animation: 300ms ease-out fade-out-scale;
}
/* Target the incoming snapshot */
::view-transition-new(article-hero) {
animation: 400ms cubic-bezier(0.2, 0, 0, 1) both slide-up-fade-in;
}
@keyframes fade-out-scale {
to { opacity: 0; transform: scale(0.96); }
}
@keyframes slide-up-fade-in {
from { opacity: 0; transform: translateY(24px); }
}The morph animation for shared elements is a special case — the browser generates it automatically using a FLIP-like technique that captures the bounding box on both pages and interpolates between them. You can override it, but in practice the default morph is good enough 90% of the time. What you will want to tweak is the animation-duration. The default 250 ms feels rushed for large elements; 400–500 ms with a snappy cubic-bezier reads much better.
You can also apply different transitions based on navigation direction — going forward vs. going back — using the @starting-style rule combined with JavaScript's navigation API. But that's a rabbit hole for a follow-up article. For most sites, a single well-tuned set of keyframes covering the forward direction is all you need.
In practice, keep your transition CSS in one dedicated file (something like transitions.css) that gets imported globally. Scattering view-transition-name overrides across component files turns into a maintenance headache fast, especially when you're trying to find the one rule that's breaking a specific page pair.
What Still Doesn't Work (and Real Workarounds)
Firefox. That's the headline. As of October 2026, Firefox ships View Transitions for same-document use but has not enabled cross-document transitions by default. It's behind a flag in Nightly. So if your audience is significantly Firefox-heavy — developer tools, open-source projects, privacy-focused SaaS — your graceful degradation story needs to be solid. The good news is it really does degrade gracefully: normal navigation, no error, no broken layout.
Cross-origin navigations don't work. If you're navigating from your marketing site on www.example.com to your app on app.example.com, you get no transition. Same-origin only. There are proposals in the spec to support cross-origin with explicit opt-in from both sides, but nothing has shipped as of this writing. Plan your subdomain strategy accordingly.
iframes are excluded. Content inside an iframe does not participate in view transitions, either as a source or a target. This affects embedded checkout widgets, third-party comment sections, and similar patterns. Nothing you can do about it right now.
One more thing — position: fixed elements are tricky. Persistent navigation bars, floating action buttons, sticky headers. The browser captures a screenshot of the old page (including the fixed nav) and a screenshot of the new page. If your nav looks identical on both pages, they'll cross-fade through each other and you'll see a double-nav ghost during the transition. The fix is to give your persistent navigation a view-transition-name: main-nav so the browser treats it as a shared element that stays put rather than two separate things that cross-fade. This is documented but easy to miss.
Pairing View Transitions with Design Systems
View Transitions work best when your design system has consistent naming conventions. If your card component always renders with the same class names, you can write transition CSS once and it applies across every card on every page. That's the dream. It's also why this API pairs so naturally with component libraries.
If you're using Empire UI components, you get consistent DOM structure out of the box — the card, hero, and modal components all use predictable class hierarchies. Adding view-transition-name as a prop to Empire UI's <Card> or <HeroImage> components is straightforward because the internal element structure doesn't shift between renders. Compare that to rolling your own components where you might restructure the DOM every few sprints and break your transition names.
For projects that lean into expressive visual styles — glassmorphism, aurora, cyberpunk — view transitions can carry the visual identity forward across pages. A glassmorphism card on a product listing page that morphs into a glassmorphism hero on the detail page feels cohesive in a way that even good static design can't fully achieve. The motion extends the design language. It signals to users that they haven't left the same product.
Look, transitions are performance-sensitive too. The browser captures GPU layer screenshots of the old and new page states. If your page has a lot of composited layers (lots of elements with will-change: transform, lots of backdrop-filter blur surfaces, video elements), the capture step can stutter. Audit your compositing layers with Chrome DevTools' Layers panel before shipping. A page with 4–5 composited surfaces transitions smoothly; one with 30+ might hitch for 80–100 ms, which defeats the purpose.
Progressive Enhancement Pattern for Production
The right way to ship this in 2026 is progressive enhancement. Your site works perfectly without transitions, and transitions layer on top for browsers that support them. The @view-transition at-rule is already an implicit enhancement — unsupported browsers just ignore it. But there are a few more things worth doing explicitly.
Feature-detect in JavaScript only when you need it for JavaScript-driven logic (like navigation direction detection). The CSS-only path needs no feature detection:
// Only needed if you're doing JS-driven transition logic
const supportsViewTransitions = 'startViewTransition' in document;
if (supportsViewTransitions) {
// Set up navigation direction tracking for back/forward animations
let navigationDirection = 'forward';
navigation.addEventListener('navigate', (e) => {
// Use Navigation API to detect traversal direction
if (e.navigationType === 'traverse') {
navigationDirection =
e.destination.index < navigation.currentEntry.index
? 'backward'
: 'forward';
}
document.documentElement.dataset.navDirection = navigationDirection;
});
}Respect prefers-reduced-motion. This isn't optional. Users with vestibular disorders or motion sensitivity explicitly told their OS they want less animation. View Transitions can trigger for every navigation, so a site without this check is actively harmful for those users:
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
}
}That snippet collapses all transition animations to near-instant for reduced-motion users while still allowing the browser to do its capture-and-swap (which avoids the flash of unstyled content you'd get from fully disabling them). Pair this with Empire UI's built-in motion tokens, which already respect prefers-reduced-motion at the component level, and you've got solid coverage without duplicating logic everywhere.
FAQ
No. The basic opt-in is pure CSS — add @view-transition { navigation: auto; } to both pages and you get the default cross-fade for free. JavaScript is only needed if you want navigation-direction-aware animations or other dynamic behaviour.
Usually one of three things: the view-transition-name value isn't identical on both pages, you forgot contain: layout on one of the elements, or two elements on the same page accidentally share the same name. Check all three before anything else.
Yes, with caveats. App Router does client-side navigation by default, so you're in same-document territory for internal links — use document.startViewTransition() there. For hard navigations or external links, the cross-document API kicks in automatically as long as both pages have the CSS opt-in.
The transition itself runs after the new page's LCP element has painted, so it doesn't delay CLS or LCP scores. That said, a janky transition that runs at 30 fps will still hurt perceived performance even if the metrics look clean. Keep your composited layer count low and test on a mid-range Android device, not just your M3 MacBook.