The View Transitions API has been on my radar for a while. I finally tried it on my Astro site with Tailwind v4, and I wanted a hard constraint.
No router. No extra JavaScript.
Turns out you can get smooth page-to-page transitions with only CSS.
Why I Tried This
Astro already feels fast. Pages stream as static HTML and navigation is snappy.
But “fast” and “connected” are different feelings.
I wanted a subtle fade that makes navigation feel continuous without looking like an animation demo.
Cross-document view transitions make that possible. The browser can animate between full page loads. If I could enable it globally, the whole site would pick up that polish without hydration.
It worked better than I expected.
The Core Idea
When you navigate between same-origin pages, the browser can animate the visual change automatically.
You start with one rule:
@view-transition { navigation: auto;}That enables transitions across the site.
After that, you decide what participates by naming elements with view-transition-name.
Two layers make this work:
- Global CSS that defines timing and easing
- Consistent HTML structure that names the same elements on every page
Implementation
There are two moving parts.
CSS defines what the transition looks like. Markup defines what the browser should “carry over” between pages.
CSS
This is the full setup I used:
@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; }}What each part does:
-
Enable navigation transitions
@view-transition { navigation: auto; }- This turns on cross-document transitions for same-origin navigation.
-
Define timing in one place
--view-transitionis the base duration.--view-transition-titleis shorter so the title leads.--easegives the motion a soft start and clean finish.
-
Target groups
rootcontrols the overall page fade.maincontrols the main content.titlemoves faster and starts slightly earlier.
-
Hint what will animate
will-change: transform, opacity;- This helps the browser prepare for movement and opacity changes.
-
Respect reduced motion
- If a user opts out, do not animate between pages.
- Also strip animation from all groups to avoid partial motion.
The result is subtle. The page fades. The title shifts first. The rest follows.
Layout
CSS alone is not enough. You need consistent names across pages.
That is what view-transition-name gives you.
<body> <main class="[view-transition-name:main]"> <h1 class="[view-transition-name:title]">Page Title</h1> <slot /> </main></body>Rules I follow:
- Do not name the
body. The browser treats it asroot. - Use one
mainand onetitleper page. - Names must match exactly on both pages.
When the browser sees the same name on the old and new document, it animates between them instead of snapping.
What I Learned
A few practical notes after testing:
-
Keep the rule global
@view-transitionmust load on every page, not just a single route.
-
Only same-origin, user-triggered navigation animates
- Full reloads and opening a new tab do not count.
-
Names must be unique per page
- One element per name per document.
-
Avoid naming overlays
- Named elements can clip their contents. Skip dropdowns, tooltips, and menus.
-
Short durations feel best
- Around 180ms keeps it snappy.
- Make the title slightly faster if you want hierarchy.
-
Reduced motion is not optional
- Disable navigation transitions and remove group animations.
-
Opt out per element
- Use
class="[view-transition-name:none]"to exclude something.
- Use
A note on nav
I leave nav unnamed.
It fades naturally with the rest of the page, which looks intentional and keeps markup simple.
If you want nav to feel “pinned” during transitions, disable its group:
::view-transition-group(nav) { animation: none;}Debugging
If nothing animates, do the boring checks first.
-
Slow it down
- If you cannot see it, you cannot debug it.
::view-transition-group(root) {animation-duration: 600ms;} -
Use DevTools
- In Chrome, open the Animations panel.
Command+Shift+Pthen “Show Animations”.
-
Confirm the basics
- CSS loads globally
- Navigation is same-origin
- Names exist on both pages and match exactly
Timing and Rhythm
Small timing changes matter.
Lead 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. Your eye tracks it. Then content settles.
Move together
::view-transition-group(title),::view-transition-group(main) { animation-duration: 180ms; animation-delay: 0ms;}Everything stays synchronized. It feels simpler, but flatter.
Those 25ms gaps decide whether motion feels designed or accidental.
Final Thoughts
This is one of those features that feels like it should require a framework.
It does not.
Astro gives you fast static navigation. The View Transitions API adds continuity. CSS handles the whole thing.
No router. No scripts. Just better page-to-page feel.