Skip to content
Astro as patterns 4 min read

View Transitions

Multi-page Astro sites are fast precisely because each navigation is a full page load with zero JavaScript by default. The trade-off is the visible flash between pages that single-page apps avoid. Astro closes that gap with the ClientRouter component, which intercepts navigation, swaps the DOM in place, and animates the change using the browser’s native View Transitions API. You get SPA-like smoothness while keeping the multi-page architecture — no client-side router framework required.

Enabling the ClientRouter

The whole feature is opt-in through a single component imported from astro:transitions and rendered inside your <head>. Add it to a shared layout so every page that uses the layout participates in transitions.

---
import { ClientRouter } from "astro:transitions";
---
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>DevCraftly</title>
    <ClientRouter />
  </head>
  <body>
    <slot />
  </body>
</html>

With ClientRouter present, Astro hijacks same-origin link clicks and history navigation. Instead of a hard reload, it fetches the next page, diffs the <head>, and replaces the <body> while playing a transition. On browsers without the View Transitions API, it falls back gracefully to a standard full-page navigation — your site keeps working everywhere.

ClientRouter was named ViewTransitions before Astro 4.5. If you see import { ViewTransitions } in older guides, the current, correct name is ClientRouter.

Default and custom animations

Out of the box, navigation uses a subtle fade. You can switch the animation per element with the transition:animate directive, which accepts a built-in name or a custom configuration.

<main transition:animate="slide">
  <slot />
</main>

The built-in values are summarized below.

ValueEffect
fadeOld content fades out, new content fades in (the default)
slideOld content slides out left, new slides in from the right
noneDisables Astro’s animation for this element
initialUses the browser’s default cross-fade with no Astro styling

You can also build a fully custom animation by importing helpers and passing an object:

---
import { fade } from "astro:transitions";
---
<header transition:animate={fade({ duration: "0.4s" })}>
  <slot name="header" />
</header>

Persisting state and identity across pages

Two directives do the heavy lifting for app-like continuity: transition:name and transition:persist.

transition:name gives an element a stable identity so the browser morphs it from one page to the next — ideal for a thumbnail that grows into a hero image. The name must be unique on each page.

---
const { post } = Astro.props;
---
<a href={`/blog/${post.slug}`}>
  <img src={post.cover} alt={post.title} transition:name={`cover-${post.slug}`} />
</a>

On the destination page, reuse the same name on the larger image and the browser animates between the two automatically.

transition:persist keeps a component or element alive across the navigation instead of recreating it. This is what lets an audio player keep playing or a sidebar keep its scroll position.

<aside transition:persist>
  <nav><!-- scroll position survives navigation --></nav>
</aside>

Persisted islands keep their state too. A <Counter client:load transition:persist /> will retain its count as the user moves between pages, because Astro reuses the existing DOM node and its hydration state.

Reacting to navigation with lifecycle events

Because the page no longer reloads, scripts that ran on DOMContentLoaded won’t re-run on every navigation. Astro emits lifecycle events on document so you can re-initialize logic at the right moment.

document.addEventListener("astro:page-load", () => {
  // Runs after the initial load AND after every transition.
  console.log("Page is ready:", window.location.pathname);
});

document.addEventListener("astro:after-swap", () => {
  // Runs immediately after the new DOM is swapped in, before paint.
  document.documentElement.dataset.theme = localStorage.theme ?? "light";
});

Output:

Page is ready: /
Page is ready: /blog/view-transitions

The key events are astro:before-preparation, astro:after-preparation, astro:before-swap, astro:after-swap, and astro:page-load. Prefer astro:page-load for setup that must run on every page, since it fires on the first load and after each transition.

Programmatic navigation

For navigation triggered by code — after a form submit, say — use the navigate helper instead of setting window.location, so the transition still plays.

import { navigate } from "astro:transitions/client";

async function onSubmit(event: SubmitEvent) {
  event.preventDefault();
  await saveDraft();
  navigate("/dashboard");
}

Best practices

  • Add ClientRouter once in a shared layout rather than per page, so transitions are consistent site-wide.
  • Reach for transition:name to create morphing hero/thumbnail effects, and keep names unique per page.
  • Use transition:persist for stateful islands (players, menus) so they survive navigation instead of remounting.
  • Move per-page initialization from DOMContentLoaded to astro:page-load, or it will only run once.
  • Keep animation durations short (200-400ms); long transitions feel sluggish on fast multi-page sites.
  • Test with the API disabled or in an unsupporting browser to confirm the full-reload fallback behaves correctly.
  • Respect prefers-reduced-motion — Astro honors it automatically, so avoid overriding it with forced custom animations.
Last updated June 14, 2026
Was this helpful?