Astro View Transitions: transition:name, animate, persist, and Fallbacks
Key takeaways
Separate the browser View Transitions API from Astro’s ClientRouter (client routing and transition control), and design element matching, animation, and state with transition:name, transition:animate, and transition:persist. Covers custom keyframes, fallback strategies, prefers-reduced-motion, script re-execution, and performance for real multi-page apps.
What this post is about
View Transitions are a web platform feature for keeping visual continuity when one view replaces another. Astro builds on that API and adds what the browser alone does not give you: client-side routing, element persistence across pages, and fallbacks for browsers without support. All of that comes from the ClientRouter component (called ViewTransitions before Astro 5). This article goes past the one-line setup and covers transition:name, transition:animate, transition:persist, custom animations, fallbacks, accessibility, performance, and the lifecycle issues that appear once a multi-page app starts routing on the client.
It assumes you know Astro layouts and pages, basic CSS animation, and how browser history (pushState) works.
Splitting responsibilities: View Transitions API vs Astro
Native browser View Transitions
The View Transitions API takes a snapshot of the old view, lets you update the DOM, takes a snapshot of the new view, and animates between the two with CSS pseudo-elements (::view-transition-old, ::view-transition-new). Same-document transitions, triggered from JavaScript with document.startViewTransition(), shipped first in Chromium (Chrome 111) and later in Safari 18 and Firefox. Cross-document transitions, which animate a normal navigation between two HTML pages, are newer: Chrome 126 and Safari 18.2 support them, while other browsers are still catching up.
What Astro’s ClientRouter adds
ClientRouter intercepts same-origin link clicks, fetches the next page, and swaps the DOM inside a same-document view transition, updating history as it goes. That keeps the server-rendered, multi-page model while giving an SPA-like feel, and it is also what makes transition:persist and the fallback animations possible. The cost is that the page is no longer reloaded: scripts, event listeners, and analytics behave differently than on a full navigation. Treat adopting it as an application lifecycle change, not only a styling change.
Enabling globally: where to put ClientRouter
In Astro 5, render the component in a shared <head>:
---
// e.g. src/components/CommonHead.astro
import { ClientRouter } from "astro:transitions";
const { title, description } = Astro.props;
---
<meta charset="utf-8" />
<title>{title}</title>
<meta name="description" content={description} />
<ClientRouter />
This enables client navigation for same-origin links on every page that includes the head, with Astro’s default fade. The transition directives below override the details per element.
transition:name: pairing elements across old and new pages
Astro tries to infer which elements on the old and new page correspond, based on their position and type in the DOM. That breaks when a card grid is reordered, when a header changes order responsively, or when the same role is rendered with a different tag. transition:name declares the identity explicitly: two elements with the same name on the old and new page are animated as one element moving and morphing.
<!-- src/pages/blog/index.astro: the card for one post -->
<img src={post.cover} alt="" transition:name={`cover-${post.slug}`} />
<!-- src/pages/blog/[slug].astro: the hero image on the post page -->
<img src={cover} alt="" transition:name={`cover-${slug}`} />
Each name must be unique on a page. In a list, build the name from a unique value such as the slug, as above. If every card in a list used the same name, the browser would refuse to run the transition at all and the page would change without animation.
transition:animate: built-in motion and page-level defaults
Built-in animations
Each element can choose an animation:
fade(the default): Astro’s crossfade, where old content fades out and new content fades in.initial: opts out of Astro’s fade and uses the browser’s default view transition styling.slide: old content slides out to the left and new content slides in from the right; on back navigation the direction is reversed.none: disables the animation for that element.
Putting none on <html> turns off the page-wide default fade, so you can animate only the content area and keep the header and navigation still:
<html lang="en" transition:animate="none">
<head>...</head>
<body>
<header>...</header>
<main transition:animate="slide">
<slot />
</main>
</body>
</html>
Tuning built-in animations
The built-in animations are also exported as functions that accept options:
---
import { fade, slide } from "astro:transitions";
---
<header transition:animate={fade({ duration: "0.4s" })}>
...
</header>
<section transition:animate={slide({ duration: "0.35s" })}>
...
</section>
Strong motion on the root element tends to expose layout shift and scroll restoration problems, which is why many product UIs keep the header and navigation at none and animate only the main content.
Custom animations: keyframes and directional objects
For custom motion, define @keyframes in a global style and pass an object of type TransitionDirectionalAnimations. It has forwards and backwards entries, and each entry describes the animation of the old and new snapshots. The name refers to a keyframes rule; duration, easing, direction, delay, and fillMode map to the matching CSS animation properties.
---
const enter = { name: "bump-in", duration: "0.3s", easing: "ease-out" };
const leave = { name: "bump-in", duration: "0.3s", easing: "ease-in", direction: "reverse" };
const bump = {
forwards: { old: leave, new: enter },
backwards: { old: leave, new: enter },
};
---
<main transition:animate={bump}>
<slot />
</main>
<style is:global>
@keyframes bump-in {
from {
opacity: 0;
transform: translateX(24px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
</style>
Here the old page plays the entering animation in reverse, so it fades out and drifts to the right, while the new page fades in from the right. The backwards entry is identical, which is the simplest option. If the motion has a direction, as slide does, back navigation usually needs mirrored keyframes (for example a bump-in-left rule with a negative offset); reusing the forward motion makes going back look like going forward.
Keep the duration short: most page transitions sit in the 200 to 400 ms range, and longer motion starts to feel like waiting. Animate only transform and opacity where possible, since both can be handled by the compositor without relayout.
transition:persist and transition:persist-props
Keeping DOM, media, and islands
transition:persist keeps the existing DOM node instead of replacing it with the corresponding node from the next page. It is meant for elements whose state would be lost on replacement: a playing video, an audio player, a canvas, an interactive map.
<video controls muted autoplay transition:persist="media-player">
<source src="/media/intro.mp4" type="video/mp4" />
</video>
Passing a value, as in transition:persist="media-player", also sets the transition name, which helps when the element sits in a different place in the tree on each page. The next page must contain a matching element (same name) for the node to be kept.
On framework islands, the component instance and its internal state carry over to the next page:
<Counter client:load transition:persist initialCount={0} />
transition:persist-props (Astro 4.5+)
By default a persisted island keeps its state but re-renders with the props from the new page. That is usually what you want: a persisted header that receives the current page title should show the new title. When the props must stay fixed as well, add transition:persist-props. Use it sparingly, because an island with frozen props can easily display data for a page the user has already left.
Fallbacks and browser support
ClientRouter works in browsers without the View Transitions API, and the fallback prop controls how:
animate(default): Astro simulates the transitions with CSS animations before swapping the content.swap: the old content is replaced immediately, with no animation.none: client-side routing is disabled in those browsers, so they get ordinary full page navigation.
---
import { ClientRouter } from "astro:transitions";
---
<ClientRouter fallback="swap" />
Because the fallback animations are a simulation, timing and layering can differ from the native version. Test in Safari and Firefox on real devices for flicker, scroll position, and focus, not only in Chrome.
Performance and accessibility
Animation cost
Animating properties that affect layout (width, height, top, left) forces relayout on every frame; transform and opacity do not. Many elements animating at once also add up, so a common approach is one named hero element plus a fade for everything else. If images or web fonts are still loading when the transition starts, the layout jump lands in the middle of the animation; explicit image dimensions and a font-display strategy reduce that.
Network
On each navigation the router fetches and parses the next page’s HTML before it can start the transition, so document size and server response time translate directly into how long a click feels. Large inline style blocks repeated on every page make each swap heavier.
prefers-reduced-motion
Astro’s built-in animations respect the user’s reduced-motion setting. Custom CSS animations need their own rule:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
Some teams keep a short fade instead of disabling motion entirely; either is reasonable as long as large movements and zooms are removed.
Patterns for multi-page apps
Full reload for specific links: data-astro-reload
For checkout, authentication, or pages whose third-party scripts expect a fresh document, turn off client routing per link:
<a href="/checkout/confirm" data-astro-reload>Confirm payment</a>
History control: data-astro-history
push, replace, or auto controls how the navigation is recorded. replace is useful when a confirmation screen should not be reachable with the back button.
Programmatic navigation: navigate()
From a select element, search results, or keyboard shortcuts, call navigate from astro:transitions/client. Do not pass unchecked user input to it: a javascript: URL or an external address turns into XSS or an open redirect. Validate against an allowlist.
import { navigate } from "astro:transitions/client";
const allowed = ["/dashboard", "/settings", "/profile"] as const;
export function go(path: string) {
if ((allowed as readonly string[]).includes(path)) {
navigate(path);
}
}
Forms (Astro 4+)
GET and POST forms are handled by the router as well. For POST, Astro sends the data as multipart/form-data by default; set enctype="application/x-www-form-urlencoded" if your endpoint expects the browser’s usual encoding. Add data-astro-reload to the form to submit with a full page load instead.
Scripts and lifecycle events
With client routing, bundled module scripts run once, on the first page load. Code that binds menus, analytics page views, or form handlers must run again on astro:page-load, which fires after the first load and after every navigation. astro:after-swap fires right after the new DOM is in place and is the place for things like re-applying a theme class before the page is painted. After navigation, also check where keyboard focus lands and whether screen readers announce the new page.
document.addEventListener("astro:page-load", () => {
document.querySelector("#menu-toggle")?.addEventListener("click", toggleMenu);
});
Troubleshooting
| Symptom | What to check |
|---|---|
| Elements don’t morph into each other | Duplicate transition:name values; the element only renders on one of the two pages |
| Animation looks wrong | transition:animate="none" on the root vs. child animations; keyframes not defined globally |
| Back navigation feels off | backwards definition, slide direction, scroll position |
| JS stops working after navigating | Module scripts run once; move setup into astro:page-load |
| Flicker in some browsers | The fallback value and the simulated animations |
When you may not need ClientRouter
ClientRouter turns an Astro site into a client-routed one: links are intercepted, the new page is fetched and swapped in, and that is why scripts, analytics and event listeners need the lifecycle events described above. If all you want is an animated transition between pages, browsers that support cross-document view transitions can do it without a router. Opt in with @view-transition { navigation: auto; } in CSS on both pages, and keep using view-transition-name to pair elements. Every navigation remains a normal page load, so scripts behave as they would without transitions, and browsers without support simply navigate without animation.
Reach for ClientRouter when you need what only client routing gives: transition:persist to keep a playing video or island state across pages, a consistent fallback animation in browsers without native support, or control over navigation through its events.