View Transitions
The best of both worlds
Section titled “The best of both worlds”A traditional multi-page site does a full browser reload on every navigation — reliable, but the screen flashes white and scroll position resets. A SPA avoids that but pays for it with a heavy JavaScript router. Astro’s view transitions give you the smooth feel of a SPA on top of a normal MPA, for a few kilobytes of script.
You opt in by adding one component, <ClientRouter />, to your page <head> — usually in a shared layout so it applies site-wide. Verified against the installed 7.3.5 virtual-module source (astro/dist/transitions/vite-plugin-transitions.js): the real, current import path is astro:transitions:
---import { ClientRouter } from 'astro:transitions';---<html lang="en"> <head> <title>My site</title> <ClientRouter /> </head> <body> <slot /> </body></html>With that in place, navigations between pages on your site are intercepted and handled on the client: Astro fetches the next page, swaps in its content, and animates the change — no full reload. Browsers that don’t support the underlying API simply fall back to a normal navigation, so nothing breaks.
The old name is really gone — proven
Section titled “The old name is really gone — proven”In older Astro the component was called <ViewTransitions />. It isn’t just deprecated or aliased in 7.3.5 — it’s not exported at all. Proven with a real build:
---import { ViewTransitions } from 'astro:transitions';---<ViewTransitions />[ERROR] [vite] ✗ Build failed[MISSING_EXPORT] "ViewTransitions" is not exported by "\0astro:transitions". ╭─[ src/pages/vt-bad-import.astro:2:10 ] 2 │ import { ViewTransitions } from "astro:transitions"; │ ───────┬─────── │ ╰───────── Missing exportIf you find <ViewTransitions /> in an old tutorial, the fix is a straight rename to <ClientRouter /> — same import specifier, same behavior.
<ClientRouter /> vs native cross-document view transitions
Section titled “<ClientRouter /> vs native cross-document view transitions”Astro’s <ClientRouter /> is a client-side router: it intercepts link clicks with JavaScript, fetches the next page itself, and swaps the DOM — the View Transitions API is what it uses to animate that swap smoothly. Modern browsers now also support cross-document view transitions natively, with zero JavaScript, purely in CSS:
@view-transition { navigation: auto;}That one declaration turns on the browser’s own full-page-navigation transition — no interception, no client-side fetch, no <ClientRouter /> at all; the browser still does a normal navigation, it just animates the swap between the old and new document. Reach for the CSS-only form when you want the visual smoothing and nothing else (no shared-element transition:name morphs, no transition:persist state retention, no lifecycle events to hook into) and want to keep zero JavaScript on the page. Reach for <ClientRouter /> when you need persisted islands, named shared-element transitions, or a hook into the navigation lifecycle (astro:page-load, etc.) — none of which the native CSS-only form gives you.
How a navigation works
Section titled “How a navigation works”flowchart LR click["User clicks a link"] --> intercept["ClientRouter intercepts (no full reload)"] intercept --> fetch["Fetch the next page HTML"] fetch --> swap["Swap in the new content, animate"] swap --> event["Fire astro:page-load"]
Transition directives — real built markup
Section titled “Transition directives — real built markup”By default Astro cross-fades the page. You control animations per element with directives:
transition:name— give an element the same name on both pages so Astro morphs one into the other (a shared-element transition, like a thumbnail growing into a hero image).transition:animate— choose the animation (fade,slide, or a custom one). Import helpers fromastro:transitions.transition:persist— keep a component’s DOM and state across the navigation instead of re-rendering it. Perfect for a playing video or an audio player that shouldn’t restart.
---import { ClientRouter } from 'astro:transitions';import { slide } from 'astro:transitions';---<html><head><title>A</title><ClientRouter /></head><body><h1 transition:name="page-title" transition:animate={slide({ duration: 200 })}>Title</h1><video controls autoplay transition:name="player" transition:persist></video></body></html>Real built output (astro build) — Astro rewrites each named element to a data-astro-transition-scope id, marks the persisted one with data-astro-transition-persist, and drops the slide() keyframes as element-scoped CSS keyed to that same scope, with a same-CSS fallback path for browsers that lack the native View Transitions API:
<body> <h1 data-astro-transition-scope="astro-cqr5yetz-1">Title</h1> <video data-astro-transition-persist="player" data-astro-transition-scope="astro-vnhdmk2z-2" controls autoplay></video></body>/* generated once per transition:name, real fence trimmed to the essentials */[data-astro-transition-scope="astro-cqr5yetz-1"] { view-transition-name: page-title; }@layer astro { ::view-transition-old(page-title) { animation-name: astroFadeOut, astroSlideToLeft; animation-duration: 200ms, 200ms; } ::view-transition-new(page-title) { animation-name: astroFadeIn, astroSlideFromRight; animation-duration: 200ms, 200ms; }}/* [data-astro-transition-fallback="old"/"new"] rules duplicate the same animation-name/-duration for browsers without the native API */transition:persist on the <video> doesn’t just skip the animation — it’s a real, separate attribute (data-astro-transition-persist) that tells the client router to keep that exact DOM node (and its running state — playback position, <audio>/<video> state, form input, an island’s in-memory state) across the swap instead of replacing it with the incoming page’s copy.
Lifecycle events are string names, not exported constants — proven
Section titled “Lifecycle events are string names, not exported constants — proven”Because navigation no longer triggers a full page load, scripts that ran on DOMContentLoaded won’t re-run on client-side navigations. Astro fires its own events, and in 7.3.5 they are plain string event names dispatched with document.dispatchEvent(new Event(name)) — verified directly in astro/dist/transitions/events.js and router.js:
astro:before-preparationastro:after-preparationastro:before-swapastro:after-swap— right after the new DOM is swapped in, before it paints (good for restoring theme/state to avoid a flash)astro:page-load— fires after every navigation (initial load and client-side swaps)
There is no exported TRANSITION_PAGE_LOAD-style constant to import — only two TypeScript class exports (TransitionBeforePreparationEvent, TransitionBeforeSwapEvent) for typing the event object itself, not its name. Proven with a real build:
---import { TRANSITION_PAGE_LOAD } from 'astro:transitions/client';---<p>{TRANSITION_PAGE_LOAD}</p>[ERROR] [vite] ✗ Build failed[MISSING_EXPORT] "TRANSITION_PAGE_LOAD" is not exported by "\0astro:transitions/client".Listen with the plain string, and re-initialize here instead of DOMContentLoaded:
document.addEventListener('astro:page-load', () => { // runs on first load AND after every client-side navigation initAnalytics();});This is the most common view-transitions gotcha: an init script that works on first load but “stops working” after navigating, because it was bound to DOMContentLoaded. Move it to astro:page-load.
navigate() and the fallback
Section titled “navigate() and the fallback”astro:transitions/client (verified in the same virtual-module source) also exports a real navigate(href, options) function — a programmatic, scripted navigation that goes through the same client router as a link click, so it animates and fires the same lifecycle events:
---import { ClientRouter } from 'astro:transitions';---<html><head><ClientRouter /></head><body><button id="go">Go</button><script> import { navigate } from 'astro:transitions/client'; document.getElementById('go')?.addEventListener('click', () => navigate('/next-page'));</script></body></html>Calling navigate() during a server-side render (rather than in response to a real browser event) logs a warning in the installed source (router.js: “The view transitions client API was called during a server side render…”) — it’s meant to run client-side, in response to user interaction.
If the visitor’s browser doesn’t support the View Transitions API at all, <ClientRouter /> still intercepts and does the client-side swap — it just skips the animation (or uses the CSS-fallback rules shown above, which some browsers can still run without the native API). Only a browser with JavaScript disabled entirely falls back to a plain, full-reload navigation.
Islands + transition:persist
Section titled “Islands + transition:persist”transition:persist on an island keeps that island’s DOM and its live component state across a client-side navigation — the component is not unmounted and remounted, it’s the literal same instance carried over. That’s different from a plain element like a <video>: for an island, “persist” means the framework component’s internal state (counters, form values, whatever’s in memory) survives, not just the DOM node and its native playback position.
Reading AI-generated code
Section titled “Reading AI-generated code”- Does a snippet
import { ViewTransitions } from 'astro:transitions'? Proven above: this is a realMISSING_EXPORTbuild error in 7.3.5, not a soft deprecation — rename toClientRouter. - Does it set caching headers on a page that also has
<ClientRouter />and expects the swapped-in content to be “live”? A cached shell served from the edge is still just HTML —<ClientRouter />doesn’t change what caching lesson applies to the response underneath it. - Does it import a lifecycle event as a named constant (
TRANSITION_PAGE_LOAD,AFTER_SWAP, etc.) instead of the plain string? Proven above: no such export exists — use the string directly withaddEventListener('astro:page-load', ...). - Is
client:loadused on a component that’s alsotransition:persist, when the component doesn’t actually need to hydrate immediately?transition:persistand hydration timing are independent — persisting doesn’t require the heaviest directive. - Does an init script still bind to
DOMContentLoadedon a site using<ClientRouter />? It will silently stop re-running after the first client-side navigation — move it toastro:page-load.
🐛 Spot the bug
---import { ViewTransitions } from 'astro:transitions';---<html><head><ViewTransitions /></head><body><slot /></body></html>Show the answer
This is not a style preference — it’s a real build error in the installed Astro 7.3.5, proven above: [MISSING_EXPORT] "ViewTransitions" is not exported by "astro:transitions". The component was renamed to ClientRouter in current Astro; the fix is import { ClientRouter } from 'astro:transitions' and <ClientRouter /> in the template — same module specifier, same slot in the <head>, just the new name.
Mental Model
Section titled “Mental Model”<ClientRouter /> is a client-side interceptor layered on top of ordinary MPA navigation, not a replacement router with its own state model — it fetches real page HTML, swaps real DOM, and fires real events you hook into (astro:page-load, not a magic re-render). transition:persist is the one escape hatch from “swap everything”: it keeps one specific element (or island, with its live state) as the literal same node instead of the incoming page’s copy of it.
Trade-offs
Section titled “Trade-offs”<ClientRouter /> |
Native @view-transition { navigation: auto } |
|
|---|---|---|
| JavaScript shipped | A few KB (the router + swap logic) | Zero |
Shared-element transition:name morphs |
Yes | No |
transition:persist (keep an island’s state) |
Yes | No — every navigation is a real full document swap |
Lifecycle hooks (astro:page-load, etc.) |
Yes | No |
| Fallback for unsupported browsers | Plain client-side swap, unanimated | Plain full-reload navigation, unanimated |
| Best for | Sites that need persisted state or fine control over what animates | Simple visual smoothing with zero added JS |
Common Misconceptions
Section titled “Common Misconceptions”- “
<ViewTransitions />still works, just deprecated.” Proven above with a real build: it’s a hardMISSING_EXPORTerror in 7.3.5, not a soft deprecation warning. - “The lifecycle events are importable constants, like
TRANSITION_PAGE_LOAD.” Proven above: no such export exists (MISSING_EXPORTonastro:transitions/client) — they’re plain string event names ('astro:page-load', etc.) dispatched ondocument. - “
transition:persiston a plain element persists arbitrary page state.” It persists that one DOM node (and, for an island, its component state) across the swap — it does not make unrelated page state (like a variable in another script) survive a navigation. - “Without
<ClientRouter />, view transitions don’t exist at all.” Modern browsers support cross-document view transitions natively via@view-transition { navigation: auto }in plain CSS — no component, no JavaScript.