View Transitions on the Web — for SPAs and Plain Old Multi-Page Sites
For the longest time, “smooth animated transitions between pages” was something only single-page apps could do. The whole point of an SPA was that nothing actually navigated - the URL changed, but the DOM persisted, and a clever animation library could fade or slide between views because it had access to both the before and the after state.
Server-rendered sites? Static sites? Tough luck. Every navigation tore down the whole document and built a new one. There was no continuity to animate across.
The View Transitions API changes this on both fronts. It gives SPAs a much simpler way to coordinate animations, and much more interestingly it gives plain HTML multi-page sites the ability to animate across navigations with a single CSS rule. This blog uses it. The titles you click on the index page morph into the titles of the post page, and there’s no JavaScript orchestrating the move.
This post is the version of the docs I wish I’d had when I started. The MDN reference at developer.mozilla.org/en-US/docs/Web/CSS/@view-transition is the spec; Dave Rupert’s getting-started post is a great gentle introduction.
How it works
A view transition does three things:
- Snapshots the current page: the browser renders the existing DOM into an offscreen image.
- Updates the DOM: your code mutates state, or a navigation occurs.
- Snapshots the new page: the new DOM is captured.
- Cross-fades between them: by default, with a 0.25s fade. With CSS, you can customise per-element.
That last step is the magic. Within the cross-fade, any element with a matching view-transition-name on both sides gets animated independently — its position, size, scale, and opacity all transitioning between the old and new state.
The default animation is a fade. But because it’s just CSS, you can override it for any named element to be a slide, a morph, a scale, anything you like.
The SPA version
For client-side framework apps, document.startViewTransition() is the single API single function call:
document.startViewTransition(() => {
// Mutate the DOM here — render new view, swap children, etc.
renderNewPage();
});
The browser handles the snapshot/cross-fade dance for you. Your only job is to do the DOM mutation inside the callback.
In CSS, you tag the elements you want to animate independently:
.post-card .title {
view-transition-name: post-title;
}
/* On the post page itself */
.post-page h1 {
view-transition-name: post-title;
}
When you click a card and the page swaps, the title on the card morphs into the heading on the post page. No measurement code, no FLIP libraries, no choreographed animation timeline. Just a name on each side.
The MPA version
This is the bit that gets me genuinely excited. As of Chrome 126 (and recent Safari/Firefox versions), the same API works across full document navigations, i.e. plain old <a href="/post/123"> links that fully reload a new HTML document.
The opt-in is one CSS at-rule:
@view-transition {
navigation: auto;
}
That’s it. With that single rule on both the source and destination pages, navigating between them gets a smooth cross-fade. Add view-transition-name to elements you want to animate independently, and they’ll morph just like in the SPA case.
Astro’s view transitions integration is a thin layer on top of this. When I navigate from the snippets list to a snippet’s detail page, the title morphs because both pages tag it with the same view-transition-name.
The performance characteristic is what surprised me - the browser is essentially keeping the old page rendered in a fixed snapshot while the new page paints in. The user sees zero white-flash. It’s better than what most SPAs achieve, because the SPA still has to do framework hydration work that the static page doesn’t.
Naming names
The single biggest gotcha is that view-transition-name must be unique per page. If you have a list of ten posts and you give all their titles view-transition-name: post-title, the browser refuses to animate any of them. It needs to match a single source to a single destination.
The fix is a per-instance name. On a list page:
<a href="/post/abc" class="card">
<h3 style="view-transition-name: post-abc">Hello world</h3>
</a>
<a href="/post/def" class="card">
<h3 style="view-transition-name: post-def">Another post</h3>
</a>
On the post page:
<h1 style="view-transition-name: post-abc">Hello world</h1>
Same name on both sides → the title morphs. Different name on the list and the post → no morph, default cross-fade.
Two practical complications:
- The names must be CSS-identifier-safe. Alphanumeric and dashes only, no spaces, no quotes, no slashes. I have a helper function which fixes cases, strips punctuation, and replaces spaces with dashes. Pass it the post title plus a prefix and you get a collision-resistant name back.
- The names must be unique across all elements on each page. If your card has the title and the hero image both named the same, the browser will fail to start the transition.
Jim Nielsen’s gotchas post was quite helpful to understand why the names should be deterministic, slug-style transform of an ID you control, saving me a couple of confusing afternoons.
Customising the animation
The default cross-fade is fine for most things. For named elements, the browser generates two pseudo-elements - ::view-transition-old(name) & ::view-transition-new(name); that you can target with normal CSS animations.
@keyframes slide-from-right {
from {
transform: translateX(50px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
::view-transition-new(post-title) {
animation: slide-from-right 0.3s ease-out;
}
For an element that exists on both pages with the same name, the browser by default morphs its bounding box. If you want a different shape, say, a slower scale-up rather than a snap morph, use ::view-transition-group(name) for the wrapper and tweak the timing.
Examples
1. SPA: element morphing
Click a card — the title morphs into the detail heading, then back again. Uses document.startViewTransition() with matching view-transition-name set on both sides before and after the DOM swap.
// Tag the source element BEFORE the transition (old snapshot)
card.querySelector(".card-title").style.viewTransitionName = "hero-title";
document.startViewTransition(() => {
// In the callback: match the same name on the destination (new snapshot)
detailTitle.style.viewTransitionName = "hero-title";
showDetailView();
hideListView();
});
2. MPA: real navigation with no JavaScript
Two separate HTML pages, each with @view-transition { navigation: auto } in their CSS and the same view-transition-name on their headings. Clicking the link is a full document navigation — no SPA, no JS. The heading still slides across.
@view-transition {
navigation: auto;
}
@keyframes slide-out-left {
to {
transform: translateX(-40px);
opacity: 0;
}
}
@keyframes slide-in-right {
from {
transform: translateX(40px);
opacity: 0;
}
}
::view-transition-old(page-heading) {
animation: slide-out-left 0.3s ease-in both;
}
::view-transition-new(page-heading) {
animation: slide-in-right 0.3s ease-out both;
}
h2 {
view-transition-name: page-heading;
}
3. Custom directional slide
The slide direction changes based on whether you go forward or back. Before calling startViewTransition(), a class is toggled on <html>. The CSS reads going-next or going-prev and picks different keyframes for ::view-transition-old and ::view-transition-new. All animation logic stays in CSS.
.going-next::view-transition-old(slide-content) {
animation: exit-to-left 0.26s ease-in both;
}
.going-next::view-transition-new(slide-content) {
animation: enter-from-right 0.26s ease-out both;
}
.going-prev::view-transition-old(slide-content) {
animation: exit-to-right 0.26s ease-in both;
}
.going-prev::view-transition-new(slide-content) {
animation: enter-from-left 0.26s ease-out both;
}
function go(newIdx) {
document.documentElement.className =
newIdx > current ? "going-next" : "going-prev";
current = newIdx;
document.startViewTransition(() => render(current));
}
Gotchas with statically generated sites
A few traps I hit moving this site to view transitions:
prefers-reduced-motion. The browser respects it, duh!, animations are skipped for users who’ve asked the OS to. Don’t manually disable transitions in CSS for accessibility; let the browser do it.- Cached pages. If a page is served from the back-forward cache, the view transition might not fire. Test with a real page reload, not the back button.
- Iframes. View transitions don’t compose into or out of iframes. The iframe transitions independently of the parent.
- Long-running JS on the destination. If the destination page does heavy synchronous work in a render-blocking script, the cross-fade will pause. Move that work into a
requestIdleCallbackor defer it. - Names that don’t change between pages. A site-wide header with
view-transition-name: site-headerwill be positioned identically and the cross-fade will look like nothing changed. That’s correct behaviour, but it can be surprising the first time.
View Transitions are one of the nest new web platform features that make sense the first time you use them. Let the browser do the cross-fade is intuitive. The CSS-only opt-in for multi-page sites is genuinely revolutionary for static blogs, marketing pages, and documentation sites that have no business being SPAs.
Three years ago, “smooth navigation between pages” implied a framework, a router, and probably a bundle larger than the content itself. Today it’s one @view-transition rule and a few tagged elements. The web platform is, finally, catching up.