Frontend Jul 6, 2026 · 7 min read

View Transitions in Astro: SPA-like navigation without the SPA weight

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

I chose Astro for several sites precisely because it serves HTML and ships very little JavaScript to the client. The classic downside of that model is that every click reloads the whole page: white flash, jumping scroll, lost state. Astro's View Transitions gave me the good part of a SPA —fluid navigation, no visible reloads, state that persists— without dragging back a client router and megabytes of bundle. This article is how I use them and, above all, where the edges are that you'll want to know before turning them on.

What problem they actually solve

In a traditional MPA site every navigation is a full document reload. It works, it's robust, and it's what Astro does out of the box, but it feels rough: between one page and the next there's a blank instant, the browser resets the scroll, and anything playing gets cut off. In a SPA that doesn't happen because you never reload: a router intercepts the click and replaces the content with JavaScript. The price is that you take on the whole SPA apparatus —the router, hydration, client state— even if your site is mostly content.

View Transitions sit in the middle. Astro intercepts the navigation, fetches the next page, swaps the <body> content without reloading the document, and animates the transition using the browser's View Transitions API. You keep serving normal HTML pages, but navigating between them stops flashing. Turning it on is one line in the layout.

---
// src/layouts/Layout.astro
import { ClientRouter } from 'astro:transitions';
---
<html lang="en">
  <head>
    <ClientRouter />
  </head>
  <body>
    <slot />
  </body>
</html>

With that <ClientRouter /> in the <head>, every internal link now navigates without a reload and with a smooth default transition. You haven't written a router or turned anything into a SPA: it's still Astro serving HTML.

Named animations: the detail that sells it

The default transition is a discreet fade, but what hooks people is being able to animate specific elements from one page to another. If I mark two elements on different pages with the same transition:name, the browser understands they're "the same" and animates the change in position and size between them. The typical case is an article thumbnail that grows into the detail page's header.

<!-- In the listing -->
<img src={post.cover} transition:name={`cover-${post.slug}`} />

<!-- On the detail page -->
<img src={post.cover} transition:name={`cover-${post.slug}`} />

The name has to be unique per element on each page; that's why I derive it from the slug. If you repeat the same transition:name for several elements visible at once, the browser can't tell which is which and the animation breaks. It's the most common mistake starting out.

The state you want to survive

Here's the less obvious win. Since there's no full reload, I can ask certain elements to persist across navigations instead of being recreated. An audio player, a heavy map, a menu with its open state: with transition:persist the element is kept as-is when moving between pages, without resetting.

<audio src={song} controls transition:persist />

Without this, every navigation would destroy the <audio> and recreate it, cutting the playback. With transition:persist, the same DOM node travels to the next page and keeps playing. It's exactly the kind of continuity people build a whole SPA for, and here I get it with an attribute.

💡 Every persisted element is a node you decide not to recreate. Use it for things that need real continuity —audio, video, an expensive map— and not as a patch to avoid recomputing something that should actually refresh with the new page.

The edges you have to know

None of this is free, and it's fair to say where it pinches. The first is scripting. Since the page doesn't reload, the <script> that ran on DOMContentLoaded doesn't run again on each navigation: it ran once and the new page doesn't fire it again. Any initialization you took for granted on every load has to be re-hooked to the astro:page-load event, which Astro emits on the initial load and after each transition.

// Runs on first load and after every navigation
document.addEventListener('astro:page-load', () => {
  initWidgets();
});

This is the bug that's hardest to spot, because the site works perfectly on a manual reload and only breaks when navigating between pages: the scripts simply weren't re-hooked.

The second edge is degradation. View Transitions rely on an API that not every browser implements the same way; where it's missing, Astro gracefully falls back to a normal navigation with reload. That means you can't treat the transition as guaranteed: it's a progressive enhancement, not a base to build logic on. And the third, more judgment than technical, is not overdoing it: animating everything is dizzying. I reserve named animations for one or two visual connections that genuinely add continuity, and leave the rest as the discreet fade.

Why it's worth it to me

I chose View Transitions in Astro rather than jumping to a SPA because I keep what I wanted from the framework —served HTML, minimal JavaScript, independent pages that cache and index well— and on top of that gain the fluidity that used to require changing the entire model. In exchange I accept a handful of concrete edges: re-hooking scripts on astro:page-load, treating the transition as progressive, and being disciplined with animations. It's a trade I almost always accept, because the cost is bounded and known, whereas moving into a SPA just to get an animation would mean paying in bundle, complexity, and SEO a price far above the problem it solves.

Yohangel Ramos

Written by Yohangel Ramos

Senior Fullstack Developer and Tech Lead. I build with React, Next.js, Nest.js and AWS — and I write about what I learn along the way.

Let's talk →

Keep reading

IA

AI-built startups in 2026: the real numbers behind the hype

IA

How to survive as a programmer in the AI era (without turning cynical or naive)