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 View Transitions API is built for exactly this case.
TL;DR: The cross-document View Transitions API animates full page navigations in a multi-page app using a single
@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 specific elements withview-transition-nameto get individual shared-element transitions, like a hero image morphing between pages instead of everything cross-fading together.
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.
Customizing 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.
Shared-Element Transitions With view-transition-name
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 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.
Respecting 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.
Scoping 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).
Old Way vs Modern Way
| 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.