
How to add page transitions with the View Transitions API in Astro?
To add page transitions in Astro, import the <ClientRouter /> component from astro:transitions and place it in the <head> of a shared layout. That single line turns your multi-page site into one that navigates on the client, fetching the next page and swapping it in with an animated crossfade powered by the browser's View Transitions API. From there you fine-tune the effect with transition:name to morph matching elements between pages, transition:animate to choose fade, slide or a custom animation, and transition:persist to keep an element, such as a playing video or a stateful island, alive across navigation.
Astro gives you two routes to page transitions: the browser's native cross-document transitions, which need no JavaScript at all, and the <ClientRouter />, which adds client-side routing and extra control. In this article you'll learn the difference between the two, how to enable the router, how each transition directive works, how to write custom animations, how to keep scripts working after navigation, how the lifecycle events fit together, and what changed in Astro 7.
Two Ways to Get Page Transitions
Before writing any code, decide which approach suits your site:
| Approach | JavaScript added | What you get | Trade-offs |
|---|---|---|---|
| Native cross-document view transitions | None | Animated navigation between normal page loads | Only in browsers that support cross-document transitions; no persistent state |
Astro's <ClientRouter /> | About 5-6 KB gzipped | Client-side routing, persistent elements, fallbacks, lifecycle events | Scripts need care because the page is no longer reloaded |
The native option keeps your site a true multi-page app. Each click is still a full page load, and the browser simply animates between the old and new document. The <ClientRouter /> intercepts the click, fetches the next page itself and swaps the DOM, which is what makes features like transition:persist possible.
The Astro docs are open about the direction of travel: as browser support for native transitions grows, you'll need the router for fewer things. If all you want is a crossfade, start with the native option.
Native Cross-Document Transitions with CSS
Cross-document view transitions are opted into with a CSS at-rule on both the old and new page. In an Astro project, the easiest place for it is a global stylesheet or a <style is:global> block in your base layout:
---
// src/layouts/BaseLayout.astro
---
<html lang="en">
<head>
<meta charset="utf-8" />
<title>{Astro.props.title}</title>
</head>
<body>
<slot />
</body>
</html>
<style is:global>
@view-transition {
navigation: auto;
}
</style>
Every same-origin navigation between pages that include this rule now crossfades. You can give elements a view-transition-name in CSS to morph them between pages, and style the animation with the ::view-transition-old() and ::view-transition-new() pseudo-elements. Browsers that don't support the at-rule ignore it and navigate normally, so there's nothing to break. Support has been arriving browser by browser, so check current compatibility data if the animation matters to you.
This approach ships no JavaScript, which keeps the benefits described in Astro's zero-JavaScript default intact. The rest of this article focuses on the <ClientRouter />, which is where Astro-specific APIs come in.
Enabling the ClientRouter
Add <ClientRouter /> to the <head> of every page that should take part in client-side navigation. A shared layout is the natural home for it, as covered in reusable layouts:
---
// src/layouts/Layout.astro
import { ClientRouter } from "astro:transitions";
interface Props {
title: string;
}
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
<ClientRouter />
</head>
<body>
<slot />
</body>
</html>
No other configuration is needed. When a visitor clicks an internal link, the router fetches the next page, calls document.startViewTransition(), swaps the content and lets the browser animate the change. The default animation is a crossfade.
Both pages in a navigation must include the router. If a visitor follows a link to a page without it, the browser performs a normal full-page load.
What the Router Adds to Your Page
When I built a two-page test project with Astro 7.4.2, the router added a module script, two meta tags and a small block of keyframe CSS to each page:
<meta name="astro-view-transitions-enabled" content="true">
<meta name="astro-view-transitions-fallback" content="animate">
<script type="module" src="/_astro/ClientRouter.astro_astro_type_script_index_0_lang.BDCjmQ8c.js"></script>
The script was 16,365 bytes on disk and about 5.7 KB gzipped. That's small, but it's not zero, so it's worth knowing what you're paying for.
Transition Directives
Astro pairs up matching elements on the old and new page automatically, based on element type and position in the DOM. Three directives give you control over that process.
transition:name
transition:name pairs two elements explicitly, so the browser morphs one into the other. The classic use is a thumbnail on a listing page that grows into the hero image on the article page:
---
// src/pages/index.astro
import Layout from "../layouts/Layout.astro";
---
<Layout title="Home">
<a href="/blog/first-post">
<img
src="/cover.svg"
alt="Cover"
width="320"
height="180"
transition:name="cover-first-post"
/>
<span transition:name="title-first-post">First post</span>
</a>
</Layout>
---
// src/pages/blog/first-post.astro
import Layout from "../../layouts/Layout.astro";
---
<Layout title="First post">
<img
src="/cover.svg"
alt="Cover"
width="960"
height="540"
transition:name="cover-first-post"
/>
<h1 transition:name="title-first-post">First post</h1>
</Layout>
Each name must be unique on a page. On a listing of many posts, build the name from the post's ID, for example transition:name={`cover-${post.id}`}, so every card has its own.
In the built HTML, Astro doesn't write the name directly onto the element. It adds a data-astro-transition-scope attribute and a generated <style> block that sets view-transition-name for that scope.
transition:animate
transition:animate chooses the animation for an element. The built-in values are:
- fade: the default crossfade, where old content fades out and new content fades in.
- slide: old content slides out to the left and new content slides in from the right, reversed when navigating back.
- initial: opts out of Astro's crossfade and uses the browser's default view transition styling.
- none: disables animation. On the
<html>element it turns off the default fade for the whole page.
You can combine a page-wide default with per-element overrides:
---
// src/layouts/Layout.astro
import { ClientRouter } from "astro:transitions";
---
<html lang="en" transition:animate="none">
<head>
<ClientRouter />
</head>
<body>
<header>...</header>
<main transition:animate="slide">
<slot />
</main>
</body>
</html>
To customise a built-in animation, import it from astro:transitions and pass options. Both fade() and slide() accept a duration:
---
// src/pages/index.astro
import { fade } from "astro:transitions";
---
<h1 transition:animate={fade({ duration: "0.4s" })}>Latest posts</h1>
transition:persist
By default, everything in the <body> is replaced on navigation. transition:persist tells the router to move an element from the old page to the new one instead, keeping its state:
<video controls muted autoplay transition:persist>
<source src="/media/intro.mp4" type="video/mp4" />
</video>
The video keeps playing as the visitor moves between pages that contain it. The same directive works on islands, which are framework components with a client directive:
<Counter client:load transition:persist initialCount={5} />
The counter's internal state survives navigation. By default the island still re-renders with the new page's props; add transition:persist-props as well if you want it to keep its old props too.
If the element lives in different components on the two pages, give both the same name so the router can match them. transition:persist accepts the name as its value as a shorthand:
<video controls muted autoplay transition:persist="media-player"></video>
Not everything can be preserved. CSS animations restart and iframes reload even with transition:persist.
Writing a Custom Animation
A custom animation is an object describing the old and new animations for both forwards and backwards navigation. First define the keyframes globally, for example in your layout:
<style is:global>
@keyframes bump {
0% {
opacity: 0;
transform: scale(1) translateX(200px);
}
50% {
opacity: 0.5;
transform: scale(1.1);
}
100% {
opacity: 1;
transform: scale(1) translateX(0);
}
}
</style>
Then describe the animation in the frontmatter of the component that uses it:
---
// src/pages/index.astro
const anim = {
old: {
name: "bump",
duration: "0.5s",
easing: "ease-in",
direction: "reverse",
},
new: {
name: "bump",
duration: "0.5s",
easing: "ease-in-out",
},
};
const bump = {
forwards: anim,
backwards: anim,
};
---
<header transition:animate={bump}>...</header>
Each animation accepts name, delay, duration, easing, fillMode and direction. You can use different objects for forwards and backwards, or arrays of animations for old and new, to build more elaborate effects.
Controlling the Router
The router handles clicks on <a> elements, back and forward navigation and form submissions. A few attributes and one function let you override it.
Forcing a Full Page Load
Add data-astro-reload to any <a> or <form> to skip client-side routing for that link:
<a href="/downloads/report.pdf" data-astro-reload>Download the report</a>
Replacing History Entries
data-astro-history sets how the navigation is written to the browser history: push adds an entry, replace overwrites the current one, and auto (the default) tries to push:
<a href="/main" data-astro-history="replace">Continue</a>
Navigating from a Script
navigate() from astro:transitions/client triggers a routed navigation from your own code:
---
// src/components/JumpMenu.astro
---
<select id="jump">
<option value="/blog">Blog</option>
<option value="/about">About</option>
</select>
<script>
import { navigate } from "astro:transitions/client";
const select = document.querySelector<HTMLSelectElement>("#jump");
select?.addEventListener("change", () => navigate(select.value));
</script>
navigate() doesn't sanitise its input. If the URL comes from a query string or other user input, check it against a list of allowed paths first, or you risk open redirects and javascript: URLs.
Fallbacks for Unsupported Browsers
In browsers without the View Transitions API, the router still performs client-side navigation and simulates the animation by default. The fallback prop changes that:
- animate (default): simulate the transitions with Astro's own CSS.
- swap: replace the old page with the new one instantly, without animation.
- none: do a normal full-page navigation.
<ClientRouter fallback="swap" />
Keeping Scripts Working After Navigation
This is where most problems come from. With the router, the page is never fully reloaded, so code that ran on DOMContentLoaded won't run again after a navigation. Astro's bundled <script> tags are module scripts, and they execute only once per visit.
The fix is to run setup code in an astro:page-load listener, which fires on the first load and after every navigation:
<script>
document.addEventListener("astro:page-load", () => {
const current = location.pathname;
document.querySelectorAll("nav a").forEach((link) => {
link.toggleAttribute(
"aria-current",
link.getAttribute("href") === current,
);
});
});
</script>
For inline scripts that must run on every navigation, add data-astro-rerun. Adding any attribute to a <script> makes it inline, so it won't be bundled:
<script is:inline data-astro-rerun>
console.log("Runs after every navigation");
</script>
If an inline script sets up global state, guard it so a second run doesn't overwrite it, for example if (!window.app) window.app = {};. The post on adding interactivity without a framework covers more script patterns that work well with the router.
The Navigation Lifecycle
The router fires five events on document, in this order:
- astro:before-preparation: navigation has started but nothing has loaded yet. Good for showing a loading indicator or changing the
direction. - astro:after-preparation: the new page has been fetched and parsed.
- astro:before-swap: just before the DOM swap. The incoming page is available as
event.newDocument, and you can replaceevent.swapwith your own implementation. - astro:after-swap: the new content is in place, the history entry and scroll position are updated, and the page hasn't painted yet.
- astro:page-load: navigation is complete and new scripts have run.
A common use of astro:before-swap is carrying a theme choice onto the incoming page before it's shown, which prevents a flash of the wrong theme, as described in the dark mode guide:
<script>
document.addEventListener("astro:before-swap", (event) => {
event.newDocument.documentElement.dataset.theme =
localStorage.getItem("theme") ?? "light";
});
</script>
And astro:after-swap can override scroll restoration:
<script>
document.addEventListener("astro:after-swap", () =>
window.scrollTo({ left: 0, top: 0, behavior: "instant" }),
);
</script>
Changes in Astro 7
Astro 7 removed the event-name constants and helpers that older code used, including TRANSITION_AFTER_SWAP, TRANSITION_PAGE_LOAD, isTransitionBeforePreparationEvent(), isTransitionBeforeSwapEvent() and createAnimationScope(). Compare event.type against the event name string instead:
// Before (Astro 6 and earlier)
// import { TRANSITION_AFTER_SWAP } from "astro:transitions/client";
// document.addEventListener(TRANSITION_AFTER_SWAP, handler);
// Astro 7
document.addEventListener("astro:after-swap", handler);
function handler(event: Event) {
if (event.type === "astro:after-swap") {
console.log("Swapped");
}
}
If you're upgrading from Astro 3 or 4, also note that the old <ViewTransitions /> component was replaced by <ClientRouter /> in Astro 5 and removed in Astro 6.
Accessibility
The router does two accessibility jobs for you:
- Route announcement: after each navigation it updates an
aria-liveregion so screen readers announce the new page. It uses the<title>, falling back to the first<h1>and then the path, so every page should have a meaningful<title>. - Reduced motion: it includes a
prefers-reduced-motionmedia query that disables all view transition animations, including the fallback animation, for visitors who ask for less motion.
Focus management is still your responsibility for custom swaps. If you write your own event.swap, use swapFunctions.saveFocus() from astro:transitions/client to restore focus afterwards, as Astro's built-in swap does.
Pairing Transitions with Prefetching
A transition can only start once the next page has arrived, so a slow network makes even a good animation feel sluggish. Prefetching fixes that by fetching likely next pages before the click. When <ClientRouter /> is on a page, prefetching is switched on by default with prefetchAll: true, so every link on the page is prefetched using the default hover strategy. The prefetch guide explains how to tune it.
FAQ: Astro View Transitions
No. Browsers that support cross-document view transitions can animate normal page loads with the CSS @view-transition at-rule set to navigation auto, and that adds no JavaScript. You need the ClientRouter for features such as transition:persist, lifecycle events and fallbacks in unsupported browsers.
It was renamed to ClientRouter in Astro 5 and the old name was removed in Astro 6. Import ClientRouter from astro:transitions and place it in the head of your layout.
Bundled scripts run only once, because the router swaps the page instead of reloading it. Move setup code into a listener for the astro:page-load event, which fires on the first load and after every navigation.
Add the data-astro-reload attribute to the link or form. The router ignores it and the browser performs a normal full-page navigation.
Yes. Add transition:persist to the island. If the same component appears on the next page, the existing instance and its state are moved across instead of being replaced. Add transition:persist-props if it should also keep its old props.
Yes. The ClientRouter includes a prefers-reduced-motion media query that turns off all transition animations, so the page content simply swaps for visitors who have asked for less motion.
Conclusion
Astro gives you a choice. The native @view-transition CSS rule animates ordinary page loads without any JavaScript, while the <ClientRouter /> adds client-side routing, persistent elements, fallbacks and a full set of lifecycle events for around 6 KB gzipped. Once the router is in place, transition:name, transition:animate and transition:persist cover almost everything you'll want to do.
The main habit to build is treating every page view as an event rather than a reload: put setup code in astro:page-load, carry state across in astro:before-swap, and compare event.type with the event name strings now that Astro 7 has removed the old constants. With that in place, transitions become a finishing touch rather than a source of bugs.


