View Transitions in Astro with CSS

5 min read View on GitHub

The View Transitions API has been on my radar for a while. I finally decided to see how it would work in practice on my Astro site using Tailwind v4, without adding any extra JavaScript.

It turns out you can get smooth, page-to-page transitions with only CSS.


Why I Tried This

Astro already makes navigation feel instant by streaming pages as static HTML. I wanted something more: a subtle fade that connects pages without feeling flashy.

With the View Transitions API now supporting cross-document navigation, browsers can animate between full page loads automatically. If I could enable it globally, Astro’s static pages could feel even smoother without a router or hydration.

That worked better than I expected.


The Core Idea

The browser can detect when you move between same-origin pages (for example, going from /blog to /about on jimmy.codes) and automatically animate the visual change. You only need a single rule:

@view-transition {
navigation: auto;
}

That single rule enables transitions across your site. From there, you can define how different parts of the page animate.

Everything comes down to two layers working together:

  • The global CSS that defines the transition behavior
  • The HTML structure that tells the browser which elements to animate between pages

Here is how I put those pieces together.


Implementation

The setup has two moving parts: CSS for the animation itself, and the layout that declares which elements should participate. Together, they create smooth, natural transitions with minimal effort.

I started with the global CSS layer since that controls how everything feels.

CSS

The CSS does all the work. It defines how the page fades, how the title moves, and how long each layer lasts.

Here is the full setup:

@view-transition {
navigation: auto;
}
@theme {
--view-transition: 180ms;
--view-transition-title: 150ms;
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
}
::view-transition-group(root) {
animation-duration: var(--view-transition);
animation-timing-function: ease-out;
}
::view-transition-group(main) {
animation-duration: var(--view-transition);
animation-timing-function: var(--ease);
}
::view-transition-group(title) {
animation-duration: var(--view-transition-title);
animation-timing-function: var(--ease);
animation-delay: 25ms;
}
::view-transition-old(title),
::view-transition-new(title) {
will-change: transform, opacity;
}
@media (prefers-reduced-motion: reduce) {
@view-transition {
navigation: none;
}
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}

Let’s unpack it.

  • @view-transition { navigation: auto; } Enables cross-document transitions for same-origin navigation. No scripts or routers required.

  • @theme variables

    • --view-transition defines the base duration for most elements.
    • --view-transition-title is slightly shorter, so the title leads the motion.
    • --ease provides a soft, responsive curve that starts smoothly and ends cleanly.
  • Groups Each ::view-transition-group() selector targets a specific part of the page.

    • root handles the full-page fade with a simple ease-out.
    • main uses the custom easing for a more natural rhythm.
    • title runs faster and starts 25 milliseconds earlier, keeping hierarchy clear.
  • will-change: transform, opacity Tells the browser to optimize rendering for these properties ahead of time. This helps prevent subpixel blur, the slight fuzziness that can occur when elements are moved by fractions of a pixel during transitions.

  • Reduced motion The prefers-reduced-motion query turns everything off for users who opt out of animation. It also disables navigation transitions entirely to avoid any motion between pages.

The result is quiet and deliberate. The page fades gently, the title glides into place just ahead of the main content, and the transition feels like part of the document rather than an effect layered on top.


Layout

The next step is telling the browser which elements should carry over between pages. That is done with the view-transition-name property:

<body>
<!-- body is treated as "root" automatically -->
<main class="[view-transition-name:main]">
<h1 class="[view-transition-name:title]">Page Title</h1>
<slot />
</main>
</body>

Use one main and one title per page. The body tag is reserved for the root animation. Other elements like main and title can be named to animate consistently between pages.

Each matching name creates a bridge between pages. When both the old and new page share that name, the browser transitions the element instead of redrawing it instantly.


What I Learned

Here are a few takeaways after testing this in Astro:

  • Do not name the body. It is already the root.
  • Keep your rule global. The @view-transition at-rule must live in CSS that loads on every page.
  • Only same-origin, user-triggered navigation animates. Reloads or new tabs will not.
  • Names must match exactly. Use one element per name on each page.
  • Avoid overflow. Named elements clip their contents, so skip naming dropdowns or tooltips.
  • Short transitions feel better. Around 180 ms is a good balance, with titles a bit faster.
  • Respect reduced motion. Disable navigation transitions in the media query and remove animations on the groups.
  • You can opt out. Use class="[view-transition-name:none]" to exclude an element.

For navigation bars, I prefer leaving them unnamed so they fade naturally with the rest of the layout. If you want them pinned during transitions, add:

::view-transition-group(nav) {
animation: none;
}

Debugging

If transitions are not working:

  1. Temporarily increase the duration to confirm the effect:

    ::view-transition-group(root) {
    animation-duration: 600ms;
    }
  2. In Chrome DevTools, open the Animations panel (Command+Shift+P → “Show Animations”).

  3. Confirm the CSS is global and pages are same-origin.


Timing and Rhythm

Small timing changes can completely shift how a transition feels.

Leading with the title:

::view-transition-group(title) {
animation-duration: 150ms;
animation-delay: 0ms;
}
::view-transition-group(main) {
animation-duration: 180ms;
animation-delay: 25ms;
}

The title finishes first, drawing the eye before content settles.

Synchronized motion:

::view-transition-group(title),
::view-transition-group(main) {
animation-duration: 180ms;
animation-delay: 0ms;
}

Everything moves together. Simpler, but with less hierarchy.

These 30–50 ms differences are subtle but shape whether motion feels intentional or mechanical.


Final Thoughts

This experiment reminded me how capable the modern web has become. What started as curiosity about a browser API turned into one of the simplest ways to make static sites feel alive.

Astro’s static output pairs well with the View Transitions API. No hydration. No scripts. Just a better experience powered by CSS.


References

Questions or Feedback?

I'd love to hear your thoughts, questions, or feedback about this post.

Reach me via email or LinkedIn.

Last updated on

Back to all posts