Astro View Transitions
View Transitions API in Astro for smooth page navigation, animations, and persistent state
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 linesView 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:persistto 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
DOMContentLoadedfor script initialization. After a view transition,DOMContentLoadeddoes not fire again. Scripts that hook into this event will silently stop working. Use theastro:page-loadevent, which fires on both initial load and every subsequent navigation. -
Using the same
transition:nameon 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 likehero-${post.id}.
Best Practices
- Add
<ViewTransitions />to your base layout so all pages benefit. Do not add it to individual pages. - Use
transition:namewith 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:persistsparingly and only for elements that genuinely need to maintain state (audio players, chat widgets, video players). - Listen to
astro:page-loadinstead ofDOMContentLoadedfor scripts that need to run after every navigation.
Common Pitfalls
- Duplicate transition names: Two elements on the same page with the same
transition:namecause 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. Useastro:page-loadorastro:after-swapevents. - Third-party scripts breaking: Libraries that hook into
DOMContentLoadedor 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:persiston 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
fadeornonefor 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
Related Skills
Astro Basics
Astro fundamentals including project structure, components, islands architecture, and templating syntax
Astro Content Collections
Content collections in Astro for managing Markdown, MDX, JSON, and YAML content with type-safe schemas
Astro Deployment
Deploying Astro sites to Vercel, Netlify, Cloudflare Pages, and other platforms
Astro Integrations
Using React, Vue, Svelte, and other UI framework islands within Astro pages
Astro Middleware
Middleware patterns in Astro for authentication, request modification, response headers, and shared context
Astro Routing
File-based and dynamic routing in Astro including static paths, rest parameters, and route priority