Astro ships View Transitions as a first-class API. Declare a viewTransitionName on two elements across routes, and the browser morphs one into the other — no JavaScript animation library needed.
But deploying that to Cloudflare Workers with Static Assets introduced a few wrinkles worth documenting.
The setup
The portfolio runs on Astro 6 with output: "static". Every page is pre-rendered at build time and served from Cloudflare’s edge as a plain HTML file. The only dynamic route is /api/brief, handled by a Worker.
View Transitions live entirely in the browser — the server doesn’t know or care. That’s the good part.
How the morph works
Two elements share a view-transition-name:
<!-- PostCard.astro (index page) -->
<h3 style={{ viewTransitionName: `thought-${slug}-title` }}>
{title}
</h3>
<!-- [slug].astro (post page) -->
<h1 style={{ viewTransitionName: `thought-${slug}-title` }}>
{title}
</h1>
When navigating between them, the browser captures snapshots of both elements and cross-fades with a morph animation. No Motion, no FLIP — pure CSS under the hood.
The gotchas
Three things bit us during deployment:
- Duplicate transition names — if two cards on the index share a name, the browser silently drops the transition. Every name must be unique per page.
- MPA vs SPA mode — Astro defaults to MPA transitions (full page loads). The
<ViewTransitions />component switches to SPA-like intercepted navigation. - Cloudflare caching — Workers cache aggressively. A stale HTML response might reference an old transition name that no longer exists on the target page.
Solving the cache problem
We added Cache-Control: no-cache to HTML responses while keeping static assets (JS, CSS, images) on long-lived caches:
// Worker fetch handler
export default {
async fetch(request: Request, env: Env) {
const response = await env.ASSETS.fetch(request);
const url = new URL(request.url);
if (url.pathname.endsWith("/") || url.pathname.endsWith(".html")) {
const headers = new Headers(response.headers);
headers.set("Cache-Control", "no-cache");
return new Response(response.body, { ...response, headers });
}
return response;
},
};
This isn’t specific to View Transitions — it’s good practice for any static site where HTML changes on every deploy but assets are content-hashed.
Performance notes
View Transitions add zero JavaScript weight when using Astro’s built-in support. The browser’s document.startViewTransition() API handles everything.
We measured with Lighthouse on a representative post page:
| Metric | Before | After |
|---|---|---|
| Performance | 98 | 98 |
| FCP | 0.8s | 0.8s |
| LCP | 1.1s | 1.1s |
| CLS | 0 | 0 |
No regression. The transition itself adds a perceived delay of ~250ms (the morph duration), but users perceive this as intentional animation, not loading time.
Reduced motion
The transitions respect prefers-reduced-motion. When the user has reduced motion enabled, Astro falls back to an instant cross-fade — still better than a hard page swap, but no swoopy morphs.
The full implementation lives in the post template and PostCard component. The pattern generalises to any pair of routes that share content — case studies use the same approach.