Skip to content

Router: View transitions & 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 */}

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 root once — 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.

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.

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 component
export 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. The transition: 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.