Router: View transitions & prefetch
Prefetch
Section titled “Prefetch”prefetch: true runs an IntersectionObserver that warms internal links as they enter the viewport — fetching the HTML response and the page’s code-split chunk (via <link rel="modulepreload"> in the prefetched head). By click time the response is in the browser cache and the navigation resolves near-instantly. Opt a link out with data-no-prefetch:
<a href="/about">About</a> {/* prefetched on viewport entry */}<a href="/huge" data-no-prefetch>Huge</a> {/* skipped */}View Transitions
Section titled “View Transitions”The swap runs inside document.startViewTransition() when the browser supports it, animating between the old and new DOM snapshots. Browsers without the API get an instant plain swap — the navigation still works, just without the animation.
What animates is decided by CSS, per the View Transitions pseudo-element tree: everything unnamed cross-fades as the root group; an element with its own view-transition-name becomes its own group. So the model is off vs opt-in:
- Suppress the default. Hold the unnamed majority still by suppressing
rootonce — then the swap is instant unless you opt an element in:::view-transition-old(root) { display: none; } /* drop the old snapshot — no flicker */::view-transition-new(root) { animation: 0s; } /* don't animate the new */ - Opt an element in by naming it. Naming alone gives it the browser’s default cross-fade (a named group animates by default); the CSS
::view-transition-old/new(NAME)rules then customise — or suppress — that animation.
transition:NAME — naming an element
Section titled “transition:NAME — naming an element”Rather than hand-write view-transition-name (a verbose Tailwind arbitrary class or an inline style), use the transition:NAME directive. phaze-compile rewrites it to the CSS property at build time. The canonical use is at the component level — naming the node a component returns, with zero boilerplate inside the component:
<Hero transition:graviton-hero/>phaze-compile emits a post-creation op that sets view-transition-name on the node the component returns — with a Fragment-safe fallback so the same emit handles both component shapes:
// what phaze-compile emits (single expression, fits the IIFE sequence):((t) => t && t.setProperty('view-transition-name', 'graviton-hero'))( __el.style || (__el.firstElementChild && __el.firstElementChild.style))Hero itself stays a plain export default function Hero() { … } — no viewTransitionName prop, no style threading, no ref.
Wrap the component body in a Fragment
Section titled “Wrap the component body in a Fragment”The Phaze convention is to return the component body wrapped in a Fragment (<>…</>). The transition: directive is designed around this shape:
// src/components/Hero.tsx — canonical Phaze componentexport default function Hero() { return ( <> <h1>Predict the peak…</h1> <div use:parallax={…} /> </> )}At runtime, a Fragment-returning component evaluates to a DocumentFragment — which has no .style. The defensive emit above falls through to the first element child (the <h1> here), and the name lands there. That’s the natural “named root” of a multi-root component: the headline element is what the user sees first; ::view-transition-old/new(graviton-hero) choreographs the visual entry/exit at that element.
An Element-returning component (single root, no Fragment) also works — __el.style is defined → the name lands directly on the wrapping element, naming the whole component as one group. Pick by intent: Fragment for “name the first visible element,” single root for “name the whole component as a group.”
Why Fragment is the convention: Phaze components are universal (SSR + client) and lean toward Fragment wrapping so the parent can compose the chrome — there’s no implicit wrapping
<div>to thread props through, no extra DOM nesting in the SSR’d HTML. Thetransition:directive is built to work with that shape unchanged.
Choreograph the animation in CSS — ::view-transition-old(NAME) for the exit, ::view-transition-new(NAME) for the enter, ::view-transition-group(NAME) for the position/size morph between the two pages:
::view-transition-old(graviton-hero) { animation: fade-out 0.2s ease-out both; }::view-transition-new(graviton-hero) { animation: fade-in 0.2s ease-in both; }It also works on a host element — <h1 transition:hero>Predict the peak</h1> merges style={{ viewTransitionName: 'hero' }} at compile time (zero runtime, SSR-serialised into the HTML). Useful for one-off naming inside a page module without lifting the element into its own component.
There’s deliberately no transition:persist or transition:animate (the compiler errors on both with a hint): persistence is structural — put the element in the Phaze App so it’s never torn down — and the animation lives in CSS, not on the element. See the DSL reference for the precise post-op shape on components.