Skip to content
DVERSE

View Transitions, deployed

Notes on shipping shared-element morphs to a static Worker

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:

  1. 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.
  2. MPA vs SPA mode — Astro defaults to MPA transitions (full page loads). The <ViewTransitions /> component switches to SPA-like intercepted navigation.
  3. 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:

MetricBeforeAfter
Performance9898
FCP0.8s0.8s
LCP1.1s1.1s
CLS00

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.