Mastering View Transitions in Astro
Deep dive into Astro View Transitions: custom animations, transition groups, lifecycle events, fallback behavior, and production patterns for smooth page navigation.
What you'll learn
- ✓How to build custom transition animations with CSS and JavaScript
- ✓How transition:name and transition groups create morph effects
- ✓How lifecycle events let you run code during transitions
- ✓How to handle fallback behavior in unsupported browsers
- ✓Production patterns: loading indicators, scroll restoration, form state
Prerequisites
- •Basic Astro setup
- •CSS animations basics
Why Go Beyond the Default Fade
Astro ships a cross-fade as the default view transition. It works, but it looks generic. Every site that uses it without customization looks the same. Custom transitions give your site a signature feel: cards that morph into detail pages, sidebars that stay put while content slides, headers that shrink on navigation.
This article assumes you have already enabled <ViewTransitions /> in your layout. If not, add it to the <head> of your base layout and come back.
Custom CSS Animations
The View Transitions API uses CSS animation names to control what happens to old and new content during a transition. You target the ::view-transition-old() and ::view-transition-new() pseudo-elements.
Slide Transition
/* src/styles/transitions.css */
/* Slide the old page out to the left */
::view-transition-old(root) {
animation: slide-out 300ms ease-in forwards;
}
/* Slide the new page in from the right */
::view-transition-new(root) {
animation: slide-in 300ms ease-out forwards;
}
@keyframes slide-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
Fade Up Transition
::view-transition-old(root) {
animation: fade-out 200ms ease-in forwards;
}
::view-transition-new(root) {
animation: fade-up 300ms ease-out forwards;
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
Import the CSS in your layout and every page transition uses it automatically.
Transition Groups with transition:name
The real power comes from named transitions. When two elements on different pages share the same transition:name, the browser morphs one into the other — position, size, and content all interpolate.
Card to Detail Page
On the list page:
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
---
<ul class="post-grid">
{posts.map((post) => (
<li>
<a href={`/blog/${post.id}`}>
<img
src={post.data.cover}
alt=""
transition:name={`post-image-${post.id}`}
/>
<h2 transition:name={`post-title-${post.id}`}>
{post.data.title}
</h2>
</a>
</li>
))}
</ul>
On the detail page:
---
// src/pages/blog/[slug].astro
const { post } = Astro.props;
const { Content } = await post.render();
---
<article>
<img
src={post.data.cover}
alt=""
transition:name={`post-image-${post.id}`}
/>
<h1 transition:name={`post-title-${post.id}`}>
{post.data.title}
</h1>
<Content />
</article>
The image and title will smoothly morph from their grid position to the detail layout. No JavaScript animation library needed.
Persisting Elements
The transition:persist directive keeps an element alive across navigations. The DOM node is not destroyed and recreated — it survives the transition. This is essential for audio players, video elements, and interactive widgets.
<!-- src/components/AudioPlayer.astro -->
<div transition:persist id="audio-player">
<audio src="/podcast.mp3" id="player"></audio>
<button onclick="document.getElementById('player').play()">Play</button>
</div>
The audio keeps playing as the user navigates. Without transition:persist, the element would be destroyed and the audio would stop.
Lifecycle Events
Astro fires events at each stage of a view transition. Use them for loading indicators, analytics, cleanup, and scroll behavior.
<script>
// Fires when navigation starts
document.addEventListener('astro:before-preparation', (event) => {
const loader = document.getElementById('page-loader');
if (loader) loader.classList.add('active');
});
// Fires after the new page is fetched but before swap
document.addEventListener('astro:after-preparation', (event) => {
console.log('New page fetched:', event.to);
});
// Fires just before the DOM swap
document.addEventListener('astro:before-swap', (event) => {
// Preserve dark mode class on the html element
const isDark = document.documentElement.classList.contains('dark');
event.newDocument.documentElement.classList.toggle('dark', isDark);
});
// Fires after the DOM swap completes
document.addEventListener('astro:after-swap', () => {
// Re-initialize third-party scripts
initAnalytics();
initSyntaxHighlighting();
});
// Fires when the transition animation finishes
document.addEventListener('astro:page-load', () => {
const loader = document.getElementById('page-loader');
if (loader) loader.classList.remove('active');
});
</script>
Preserving Dark Mode
The astro:before-swap event gives you access to event.newDocument, the new DOM before it replaces the old one. This is the right place to copy state that lives on the <html> element:
<script>
document.addEventListener('astro:before-swap', (event) => {
// Copy theme preference to new document
const theme = document.documentElement.dataset.theme;
if (theme) {
event.newDocument.documentElement.dataset.theme = theme;
}
});
</script>
Direction-Aware Transitions
You can change the animation based on whether the user navigates forward or backward:
<script>
// Track navigation history depth
let navIndex = 0;
const navHistory = new Map();
document.addEventListener('astro:before-preparation', (event) => {
const currentPath = window.location.pathname;
const targetPath = new URL(event.to).pathname;
if (navHistory.has(targetPath) && navHistory.get(targetPath) < navIndex) {
document.documentElement.dataset.direction = 'back';
} else {
document.documentElement.dataset.direction = 'forward';
}
});
document.addEventListener('astro:page-load', () => {
navHistory.set(window.location.pathname, navIndex++);
});
</script>
/* Forward: slide from right */
[data-direction="forward"] ::view-transition-old(root) {
animation: slide-out-left 250ms ease-in;
}
[data-direction="forward"] ::view-transition-new(root) {
animation: slide-in-right 250ms ease-out;
}
/* Back: slide from left */
[data-direction="back"] ::view-transition-old(root) {
animation: slide-out-right 250ms ease-in;
}
[data-direction="back"] ::view-transition-new(root) {
animation: slide-in-left 250ms ease-out;
}
Fallback Behavior
Not all browsers support the View Transitions API. Astro handles this gracefully: in unsupported browsers, navigation still works — it just falls back to a normal full-page load. But you can provide an explicit fallback animation:
---
// src/layouts/Base.astro
import { ViewTransitions } from 'astro:transitions';
---
<html>
<head>
<ViewTransitions fallback="animate" />
</head>
<body>
<slot />
</body>
</html>
The fallback prop accepts:
"animate"— uses a CSS-only crossfade as fallback"swap"— instantly swaps without animation"none"— no client-side navigation at all in unsupported browsers
Production Patterns
Loading Indicator
---
// src/components/PageLoader.astro
---
<div id="page-loader" class="page-loader" aria-hidden="true">
<div class="loader-bar"></div>
</div>
<style>
.page-loader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
z-index: 9999;
opacity: 0;
transition: opacity 100ms;
}
.page-loader.active {
opacity: 1;
}
.loader-bar {
height: 100%;
background: var(--accent);
animation: loading 1s ease-in-out infinite;
transform-origin: left;
}
@keyframes loading {
0% { transform: scaleX(0); }
50% { transform: scaleX(0.7); }
100% { transform: scaleX(1); }
}
</style>
Scroll Restoration
By default, Astro scrolls to the top on navigation. For a sidebar layout where you want the sidebar to keep its scroll position:
<script>
let sidebarScroll = 0;
document.addEventListener('astro:before-swap', () => {
const sidebar = document.getElementById('sidebar');
if (sidebar) sidebarScroll = sidebar.scrollTop;
});
document.addEventListener('astro:after-swap', () => {
const sidebar = document.getElementById('sidebar');
if (sidebar) sidebar.scrollTop = sidebarScroll;
});
</script>
Disabling Transitions on Specific Links
Sometimes a link should do a full page load — for example, a link to a different subdomain or an admin panel:
<a href="/admin" data-astro-reload>Admin Panel</a>
The data-astro-reload attribute tells Astro to skip client-side navigation for that link.
Debugging Transitions
Chrome DevTools has a dedicated Animations panel. Open it during a transition to see the timeline, slow down animations, and inspect the ::view-transition pseudo-elements in the Elements panel.
You can also slow down transitions programmatically during development:
/* Only in development */
::view-transition-group(*) {
animation-duration: 2s !important;
}
Common Mistakes
-
Too many named transitions: Each named transition creates a separate snapshot layer. More than 10-15 named transitions on a page can cause visual glitches and performance issues.
-
Forgetting
transition:persistre-runs scripts: Persisted elements do not re-run their inline scripts. If your component needs re-initialization, use theastro:page-loadevent instead. -
Heavy images in morph transitions: Morphing a small thumbnail into a large hero image works visually, but the browser captures a raster snapshot. If the source is low-resolution, the morph looks blurry. Preload the full-resolution image to avoid this.
-
Not testing without JavaScript: View transitions are a progressive enhancement. Your site must work without them. Test with JavaScript disabled regularly.
Summary
View transitions transform Astro sites from “collection of pages” to “coherent experience.” Named transitions create visual continuity between pages. Lifecycle events let you preserve state, show loading indicators, and run cleanup. Direction-aware animations add polish. And because it is all progressive enhancement, the site still works perfectly when the API is not available.
Related articles
- Astro Astro View Transitions: A Practical Tutorial
Learn how to add smooth, native page transitions to your Astro site using the View Transitions API with practical examples and gotchas.
- Astro Advanced Content Collections in Astro
Go beyond basic content collections: schema references between collections, computed fields, custom loaders, advanced querying patterns, and full type safety across your Astro project.
- Astro Building and Using Astro Integrations
How Astro integrations work under the hood: using official integrations, configuring MDX and Tailwind, and building your own custom integration with lifecycle hooks.
- Astro Authentication Patterns in Astro
Implement authentication in Astro: cookie-based sessions, JWT tokens, OAuth flows, protected routes with middleware, and patterns for both SSR and hybrid modes.