Skip to content
Codeloom
Astro

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.

·7 min read · By Codeloom
Intermediate 12 min read

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>

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

  1. 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.

  2. Forgetting transition:persist re-runs scripts: Persisted elements do not re-run their inline scripts. If your component needs re-initialization, use the astro:page-load event instead.

  3. 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.

  4. 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.