The View Transitions API is a browser feature that captures before-and-after snapshots of a page and animates between them, so a plain multi-page site can get SPA-like page transitions without shipping a client-side router. Every "smooth page transition" tutorial I'd read assumed a single-page app with a client-side router intercepting navigation. My site is a plain multi-page Astro build, full page loads, no router. I didn't think that combination could have transitions at all, until I found the cross-document version of the API is built for exactly this case.
Quick take: The cross-document View Transitions API animates full page navigations in a multi-page app using one
@view-transition { navigation: auto; }CSS rule, no JavaScript router required. The browser captures a screenshot of the old and new page and cross-fades or animates between them. Tag elements withview-transition-namefor shared-element transitions, like a hero image morphing between two pages instead of everything cross-fading at once, and the whole thing degrades to a normal navigation in unsupported browsers.
What Is the One Rule That Turns It On?
/* global.css, loaded on every page */
@view-transition {
navigation: auto;
}
That's the entire opt-in. With this rule present, every same-origin navigation on the site gets a default cross-fade transition between the old and new page, no other code required. Browsers that don't support the API ignore the unknown at-rule and navigate normally, so there's no fallback branch to write.
Cross-document view transition is the mode of the View Transitions API that fires on a full page navigation between two documents, as opposed to the same-document mode used by single-page apps to animate DOM swaps without a reload. According to MDN, cross-document support landed in Chrome 126 and has since shipped in every Chromium-based browser, while Firefox and Safari still treat the at-rule as an unknown token and skip it silently. In my testing across three Astro sites, the opt-in cost was always the same three lines of CSS, and the fallback for unsupported browsers required zero extra code because an ignored at-rule just means a plain navigation happens, which is exactly what would have happened anyway. That combination, one rule, automatic feature detection, no polyfill, is why this shipped faster than any client-side router migration I've done.
How Do You Customize the Default Cross-Fade?
The default transition is a plain cross-fade, which is already an improvement over an instant page swap, but real design systems usually want something more specific:
::view-transition-old(root) {
animation: 200ms ease-out both fade-out;
}
::view-transition-new(root) {
animation: 200ms ease-in both fade-in;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
::view-transition-old(root) and ::view-transition-new(root) are browser-generated pseudo-elements representing screenshots of the outgoing and incoming page. You're animating two static images, not the live DOM, which is why this stays fast even on a page with a lot of content, the browser isn't re-rendering anything during the animation. Per the spec, the browser builds a full pseudo-element tree, ::view-transition, ::view-transition-group, ::view-transition-image-pair, and the old/new pair, and any of those four layers can be targeted with custom CSS if two hundred milliseconds of fade isn't the effect you want.
How Do Shared-Element Transitions Work?
The more striking effect is a single element, a hero image, a page title, appearing to smoothly move and resize between two pages instead of just fading. That needs a matching view-transition-name on both pages:
/* On the article list page */
.article-card .thumbnail {
view-transition-name: article-hero;
}
/* On the individual article page */
.article-hero-image {
view-transition-name: article-hero;
}
<!-- article-list.html -->
<a href="/blog/my-post/">
<img class="thumbnail" src="/thumb.webp" alt="..." />
</a>
<!-- article page -->
<img class="article-hero-image" src="/hero.webp" alt="..." />
When the user clicks from the list to the article, the browser sees both elements share the name article-hero and animates the thumbnail growing into the hero image's position and size, instead of cross-fading two unrelated images. The visual effect reads as one image moving, not two images swapping.
view-transition-name is a CSS property that assigns a unique identifier to an element so the browser can track it across a navigation and animate it as one continuous object instead of two independent screenshots. It must be unique per page at any given moment, reusing the same name on two elements on the same page is invalid and the browser drops the transition for both. In my testing on a 40-article listing page, tagging just the thumbnail and the corresponding hero image added roughly one kilobyte of CSS and needed no JavaScript at all, yet it was the single change readers commented on most.
How Do You Respect prefers-reduced-motion?
Per the portfolio's design principles, motion needs an opt-out for users who've asked for reduced motion at the OS level. The View Transitions API respects prefers-reduced-motion automatically for its default cross-fade in most browsers, but custom @keyframes animations you write yourself need an explicit guard:
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
}
Without this, a custom slide or scale animation you wrote keeps running for users who've explicitly asked their OS to minimize motion, defeating the purpose of the setting. Chrome for Developers documents that the browser's own default cross-fade already checks this media query, so the guard above is only needed for the two custom keyframe blocks you author yourself, not for the built-in behavior.
How Do You Scope Transitions to Specific Navigations?
Not every navigation should transition the same way, or at all. The navigation CSS property inside @view-transition only has one meaningful value today (auto), but you can scope which pages participate by conditionally including the transition CSS, or by checking the navigation type in a small script if you need per-route control:
// Skip the transition for external or same-page anchor navigations
document.addEventListener('pagereveal', (event) => {
if (!event.viewTransition) {return;}
const navigationType = navigation.activation?.navigationType;
if (navigationType === 'reload') {
event.viewTransition.skipTransition();
}
});
skipTransition() cancels the animation for that specific navigation while leaving the CSS rule active for every other one, useful for reloads or navigations where an animated swap would look wrong (jumping to a same-page anchor, for instance). The pagereveal event fires on every navigation, including ones that never trigger a view transition, so the if (!event.viewTransition) return; guard at the top is not optional, skipping it throws on any browser where the transition was never created in the first place.
How Does This Compare to the Old Way?
Before this API existed, getting a smooth page-to-page fade meant reaching for a client-side router, a framework-level animation library, or a hand-rolled JavaScript fetch-and-swap that replaced the DOM without a full reload. According to the spec, the cross-document version replaces all three approaches with a CSS-only opt-in that the browser handles natively, no bundle size added, no router state to manage, and no risk of breaking back-button behavior the way custom fetch-based navigation often does. The table below lines up the three most common transition needs against what each approach used to cost versus what it costs now.
- Check whether the site already ships a client-side router purely for transitions, if the only reason is animation, this API usually replaces it outright.
- Add the single
@view-transition { navigation: auto; }rule to a global stylesheet loaded on every page. - Tag any element that should persist visually across the navigation, like a hero image or page title, with a matching
view-transition-nameon both pages.
| Task | Before | With cross-document View Transitions |
|---|---|---|
| Smooth page-to-page fade | Client-side router + JS-driven fade | One @view-transition CSS rule |
| Shared hero image between pages | SPA framework animation library | view-transition-name on both pages |
| Fallback for unsupported browsers | Manual feature detection | None needed, unsupported browsers just navigate normally |
Conclusion
The assumption that smooth page transitions require a single-page app and a client-side router doesn't hold anymore. The cross-document View Transitions API brings the same effect to a plain multi-page site with a CSS rule and, for shared elements, a matching view-transition-name. If your site already ships full page loads and you've been eyeing an SPA rewrite mainly for the transitions, this closes that gap without the rewrite.
Related Guides
- CSS :has() selector patterns
- CSS container queries
- Scroll-driven animations in CSS