Skip to content
~/waves

View Transitions x Astro Islands: Buttery Navigation on a Tight Budget

Published 4 min read

Budget first, animation second

Set the performance budget up front, or the shiny demos will erode it before you notice:

MetricTargetMeasured (home page)
First-paint JS< 50 KB (gzip)12 KB
LCP< 1.5 s0.9 s
CLS< 0.050.01
INP (worst 10%)< 200 ms110 ms

Any animation that regresses one of these by more than 10% gets rolled back. Hard cap.

View Transitions: the browser does the work

Chrome 111+ and Safari 18+ ship the View Transitions API natively. Astro 5 wraps it as <ClientRouter />:

---
import { ClientRouter } from 'astro:transitions';
---
<html lang={lang}>
  <head>
    <ClientRouter />                            
    <slot name="head" />
  </head>
  <body>
    <slot />
  </body>
</html>

That single element does three things:

  1. Intercepts in-site link clicks and swaps in fetch + document.startViewTransition
  2. Replaces <body> while keeping any <head> nodes marked transition:persist
  3. Falls back gracefully to a normal navigation in unsupported browsers

No React, no framer-motion, zero extra dependencies.

Directives I actually use

DirectivePurposeTypical use case
transition:name="x"Shared element across pages, morphList card cover -> article hero
transition:animate="slide"Choose the directionPrevious / next article
transition:persistKeep a node across pagesSticky audio player, command palette
transition:persist-propsKeep React props tooPersistent counter island

The everyday pattern:

<a href={`/posts/${slug}/`}>
  <img
    src={cover}
    alt={title}
    transition:name={`cover-${slug}`}            // [!code highlight]
  />
  <h2 transition:name={`title-${slug}`}>{title}</h2>  // [!code highlight]
</a>

The detail page’s matching <img> and <h2> morph into place automatically. Zero custom CSS.

Islands: interactivity with a price tag

View Transitions handle motion between pages. Islands handle interactivity within a page. One rule:

Static by default. Every new island has to answer “how many times per month is this actually used?”

Islands currently live on the site:

// Command palette: cmd+K / ctrl+K, reuses cmdk
export { default as CommandMenu } from './CommandMenu';   

// Theme toggle: must be client:load to avoid FOUC
export { default as ThemeToggle } from './ThemeToggle';   

// Comments: lazy import, only fetched when in viewport
export { default as Comments } from './Comments';         

// Reading time: computed at build time, no island needed
// export { default as ReadingTime } from './ReadingTime';

What client:* actually costs

<!-- Loads immediately; enters the critical parsing path. Use sparingly. -->
<ThemeToggle client:load />

<!-- Loads when the browser is idle. The default 80% choice. -->
<CommandMenu client:idle />                     

<!-- Loads when scrolled into view. Footer newsletter form. -->
<NewsletterForm client:visible />

<!-- Loads only when the media query matches. Mobile-only nav. -->
<MobileNav client:media="(max-width: 768px)" />

client:idle is the right default for almost everything. Reserve client:load for things that look broken if they’re not interactive immediately — the theme toggle, for instance, flashes the wrong palette without it.

Sharp edges where the two meet

Edge 1: third-party scripts run twice

<ClientRouter /> re-executes <head> scripts that aren’t transition:persist. Plausible, Cloudflare Insights, and friends re-initialize each navigation.

<script
  is:inline
  data-domain="example.com"
  src="/js/analytics.js"
  transition:persist                              <!-- [!code highlight] -->
></script>

With transition:persist the node survives the swap and is not re-inserted.

Edge 2: duplicate view-transition-name

/* All cards share the same name; the transition silently fails */
.card img { view-transition-name: cover; }       

/* Make each image unique */
.card img { view-transition-name: var(--cover-name); }  

Unique names come from frontmatter slugs, set via style={—cover-name: cover-${slug}} in the template.

Edge 3: prefetch and transitions feed each other

astro.config.mjs enables:

prefetch: { prefetchAll: true, defaultStrategy: 'viewport' }

Links in the viewport have their HTML already cached. Click -> startViewTransition immediately has both old and new DOM. Transitions feel nearly instant. Best free lunch on the menu.

Measure, don’t vibe

“Feels fast” doesn’t ship. I run these regularly:

# Lighthouse CI, budget defined in .lighthouserc.json
npx lhci autorun --collect.url=http://localhost:4322/

# Web Vitals live (during dev)
pnpm dev
# In the browser console:
#   import('https://unpkg.com/web-vitals?module').then(v => v.onINP(console.log))

Any animation has to clear both before it lands. Great-feeling motion with INP over 300 ms gets cut.

← Back to posts