Skip to main content
Technology & EngineeringAstro351 lines

Astro View Transitions

View Transitions API in Astro for smooth page navigation, animations, and persistent state

Quick Summary24 lines
You are an expert in Astro's View Transitions API for building smooth, app-like page navigation on multi-page websites.

## Key Points

- Add `<ViewTransitions />` to your base layout so all pages benefit. Do not add it to individual pages.
- Use `transition:name` with unique, stable identifiers (like database IDs) for elements that should morph between pages.
- Keep transition durations short (200ms to 400ms). Longer animations feel sluggish and interfere with rapid navigation.
- Use `transition:persist` sparingly and only for elements that genuinely need to maintain state (audio players, chat widgets, video players).
- Listen to `astro:page-load` instead of `DOMContentLoaded` for scripts that need to run after every navigation.
- **Duplicate transition names**: Two elements on the same page with the same `transition:name` cause undefined behavior. Names must be unique per page.
- **Third-party scripts breaking**: Libraries that hook into `DOMContentLoaded` or manipulate the DOM on load may not reinitialize after navigation. Use lifecycle events to re-initialize them.
- **Large page diffs causing jank**: If the old and new pages have very different structures, the morph animation may look jarring. Use `fade` or `none` for pages with drastically different layouts.

## Quick Example

```astro
<!-- Page: /blog/[id] -->
<article>
  <img transition:name={`hero-${post.id}`} src={post.data.heroImage} />
  <h1>{post.data.title}</h1>
</article>
```
skilldb get astro-skills/Astro View TransitionsFull skill: 351 lines
Paste into your CLAUDE.md or agent config

View Transitions — Astro

You are an expert in Astro's View Transitions API for building smooth, app-like page navigation on multi-page websites.

Overview

Astro's View Transitions provide animated page transitions for multi-page applications (MPAs) without a client-side router. When enabled, Astro intercepts navigation, fetches the new page, and uses the browser View Transitions API (with a fallback for unsupported browsers) to animate between old and new content.

Core Concepts

Enabling View Transitions

Add the <ViewTransitions /> component to your layout's <head>:

---
// src/layouts/Base.astro
import { ViewTransitions } from 'astro:transitions';
---

<html>
  <head>
    <meta charset="utf-8" />
    <title>{Astro.props.title}</title>
    <ViewTransitions />
  </head>
  <body>
    <slot />
  </body>
</html>

Once added, all navigation between pages using this layout is intercepted and animated.

Transition Directives

Control how individual elements animate during transitions:

---
import { fade, slide } from 'astro:transitions';
---

<!-- Named transition — Astro matches elements with the same name across pages -->
<header transition:name="main-header">
  <h1>My Site</h1>
</header>

<!-- Built-in fade animation -->
<main transition:animate={fade({ duration: '0.3s' })}>
  <slot />
</main>

<!-- Built-in slide animation -->
<aside transition:animate={slide({ duration: '0.2s' })}>
  Sidebar
</aside>

<!-- Persist element across navigations (keeps state, does not re-render) -->
<video transition:persist autoplay>
  <source src="/bg-video.mp4" type="video/mp4" />
</video>

Transition Names

Elements with the same transition:name on the old and new page are morphed between each other:

<!-- Page: /blog -->
<ul>
  {posts.map(post => (
    <li>
      <img transition:name={`hero-${post.id}`} src={post.thumbnail} />
      <a href={`/blog/${post.id}`}>{post.data.title}</a>
    </li>
  ))}
</ul>
<!-- Page: /blog/[id] -->
<article>
  <img transition:name={`hero-${post.id}`} src={post.data.heroImage} />
  <h1>{post.data.title}</h1>
</article>

The thumbnail image morphs into the hero image when navigating between the list and detail pages.

Built-in Animations

Astro provides three built-in animation presets:

---
import { fade, slide, morph } from 'astro:transitions';
---

<!-- Crossfade (default when no animation specified) -->
<div transition:animate="initial">Default morph</div>

<!-- Fade in/out -->
<div transition:animate={fade({ duration: '0.4s' })}>Fades</div>

<!-- Slide from the side -->
<div transition:animate={slide({ duration: '0.3s' })}>Slides</div>

<!-- No animation, instant swap -->
<div transition:animate="none">Instant</div>

Persistence

Use transition:persist to keep an element alive across navigations. The DOM node is moved to the new page without re-mounting:

<!-- Audio player continues playing across page navigations -->
<div transition:persist id="audio-player">
  <audio src="/podcast.mp3" controls autoplay />
</div>

<!-- Interactive island keeps its state -->
<Counter client:load transition:persist initialCount={0} />

Implementation Patterns

Custom Animations with CSS

Define custom animations using the transition:animate directive with a configuration object:

---
const customFade = {
  forwards: {
    old: [
      { opacity: 1, transform: 'scale(1)' },
      { opacity: 0, transform: 'scale(0.95)' },
    ],
    new: [
      { opacity: 0, transform: 'scale(1.05)' },
      { opacity: 1, transform: 'scale(1)' },
    ],
  },
  backwards: {
    old: [
      { opacity: 1, transform: 'scale(1)' },
      { opacity: 0, transform: 'scale(1.05)' },
    ],
    new: [
      { opacity: 0, transform: 'scale(0.95)' },
      { opacity: 1, transform: 'scale(1)' },
    ],
  },
  duration: '0.3s',
};
---

<main transition:animate={customFade}>
  <slot />
</main>

Navigation Lifecycle Events

Listen to view transition events to run code at specific points during navigation:

<script>
  document.addEventListener('astro:before-preparation', (event) => {
    // Runs before the new page is fetched
    // event.to — the destination URL
    // event.from — the source URL
    console.log(`Navigating from ${event.from} to ${event.to}`);
  });

  document.addEventListener('astro:after-preparation', (event) => {
    // Runs after the new page HTML is loaded but before swap
  });

  document.addEventListener('astro:before-swap', (event) => {
    // Runs just before the DOM is updated
    // You can modify event.newDocument before it replaces the current page
    const theme = document.documentElement.dataset.theme;
    event.newDocument.documentElement.dataset.theme = theme;
  });

  document.addEventListener('astro:after-swap', () => {
    // Runs after the DOM has been updated, before animations finish
    // Re-initialize imperative scripts here
  });

  document.addEventListener('astro:page-load', () => {
    // Runs when the page is fully loaded (initial load + every navigation)
    // Use this instead of DOMContentLoaded
  });
</script>

Re-initializing Scripts After Navigation

Scripts only run once during initial page load by default. Use astro:page-load to re-run them after each navigation:

<script>
  function initializeMenu() {
    const toggle = document.getElementById('menu-toggle');
    const menu = document.getElementById('menu');

    toggle?.addEventListener('click', () => {
      menu?.classList.toggle('open');
    });
  }

  // Runs on initial load and after every view transition
  document.addEventListener('astro:page-load', initializeMenu);
</script>

Preserving State Across Navigation

---
// src/layouts/Base.astro
import { ViewTransitions } from 'astro:transitions';
---

<html>
  <head>
    <ViewTransitions />
  </head>
  <body>
    <!-- Persistent nav with active state maintained by the island -->
    <nav transition:persist>
      <NavBar client:load />
    </nav>

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

    <!-- Persistent footer player -->
    <footer transition:persist id="player">
      <MusicPlayer client:load />
    </footer>
  </body>
</html>

Conditional Transitions

Control when view transitions are used:

<a href="/about">Normal transition</a>

<!-- Skip view transition for this link -->
<a href="/download" data-astro-reload>Full page reload</a>

<!-- Prefetch on hover (default) or other strategies -->
<a href="/blog" data-astro-prefetch>Prefetched link</a>
<a href="/blog" data-astro-prefetch="viewport">Prefetched when visible</a>

Loading Indicators

Show a progress indicator during slow navigations:

<script>
  let timeout;

  document.addEventListener('astro:before-preparation', () => {
    timeout = setTimeout(() => {
      document.getElementById('loading-bar')?.classList.add('active');
    }, 200); // Only show if navigation takes more than 200ms
  });

  document.addEventListener('astro:after-swap', () => {
    clearTimeout(timeout);
    document.getElementById('loading-bar')?.classList.remove('active');
  });
</script>

<div id="loading-bar"></div>

<style>
  #loading-bar {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 3px;
    background: transparent;
    z-index: 9999;
    transition: background 0.2s;
  }
  #loading-bar.active {
    background: linear-gradient(90deg, #3b82f6, #8b5cf6);
    animation: loading 1s ease-in-out infinite;
  }
  @keyframes loading {
    0% { transform: translateX(-100%); }
    100% { transform: translateX(100%); }
  }
</style>

Core Philosophy

Astro's View Transitions represent a fundamental belief that multi-page applications can feel as smooth as single-page apps without adopting a client-side router or JavaScript framework. By intercepting navigation and using the browser's View Transitions API, Astro delivers animated page transitions while preserving the MPA architecture's simplicity: real URLs, real page loads, and real browser navigation semantics.

The transition system is designed around the principle that animation should enhance content, not obscure it. Short, purposeful transitions (200-400ms) provide visual continuity as users move between pages. The transition:name directive creates semantic connections between elements across pages, letting a thumbnail morph into a hero image or a list item expand into a detail view. These connections communicate spatial relationships in the UI rather than just looking flashy.

Persistence is the most powerful and most dangerous feature of the transition system. transition:persist keeps a DOM element alive across navigations, which is essential for audio players, chat widgets, and components that must maintain state. But persistence comes at a cost: persisted elements bypass the normal page lifecycle, which can lead to stale state, memory leaks, and surprising behavior if overused. The philosophy is to persist only what genuinely must survive navigation, and let everything else re-render fresh.

Anti-Patterns

  • Adding <ViewTransitions /> to individual pages instead of the base layout. This creates inconsistent navigation behavior where some page transitions are animated and others are not. Apply it once in your shared layout and let all pages inherit it.

  • Using long transition durations. Animations longer than 400ms feel sluggish and interfere with rapid navigation. Users clicking through pages quickly will be frustrated by animations that have not finished before they navigate again.

  • Persisting large component subtrees. Wrapping entire sections in transition:persist to avoid re-renders defeats the purpose of MPA architecture. Only persist leaf-level elements that genuinely need continuous state (media players, form inputs mid-edit).

  • Relying on DOMContentLoaded for script initialization. After a view transition, DOMContentLoaded does not fire again. Scripts that hook into this event will silently stop working. Use the astro:page-load event, which fires on both initial load and every subsequent navigation.

  • Using the same transition:name on multiple elements within a single page. Duplicate transition names on one page cause undefined morphing behavior. Names must be unique per page, typically incorporating a dynamic ID like hero-${post.id}.

Best Practices

  • Add <ViewTransitions /> to your base layout so all pages benefit. Do not add it to individual pages.
  • Use transition:name with unique, stable identifiers (like database IDs) for elements that should morph between pages.
  • Keep transition durations short (200ms to 400ms). Longer animations feel sluggish and interfere with rapid navigation.
  • Use transition:persist sparingly and only for elements that genuinely need to maintain state (audio players, chat widgets, video players).
  • Listen to astro:page-load instead of DOMContentLoaded for scripts that need to run after every navigation.

Common Pitfalls

  • Duplicate transition names: Two elements on the same page with the same transition:name cause undefined behavior. Names must be unique per page.
  • Scripts not re-running: After a view transition, inline <script> tags in the new page do not re-execute because the <script> has already been registered. Use astro:page-load or astro:after-swap events.
  • Third-party scripts breaking: Libraries that hook into DOMContentLoaded or manipulate the DOM on load may not reinitialize after navigation. Use lifecycle events to re-initialize them.
  • Form state lost: If a user is filling out a form and clicks a link, the view transition replaces the page and form data is lost. Use transition:persist on form containers or warn users about unsaved changes.
  • Large page diffs causing jank: If the old and new pages have very different structures, the morph animation may look jarring. Use fade or none for pages with drastically different layouts.
  • Browser fallback behavior: In browsers that do not support the View Transitions API natively, Astro uses a fallback that provides the same functionality but without hardware-accelerated animations.

Install this skill directly: skilldb add astro-skills

Get CLI access →