Skip to content

Router: View transitions, prefetch & speculation

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 */}

Where prefetch warms the response, Speculation Rules let the browser build the whole next document — parsed, scripted, hydrated — in a hidden frame, then swap it in on click. Enable it with an eagerness:

vite.config.ts
cloudflare({ router: true, speculationRules: 'moderate' })

The value states the signal that arms the browser, so the choice is visible in the config rather than buried in the framework:

eagernessfires on
immediateas soon as the rule is parsed
eagerthe earliest hint of intent — pointer moving toward the link
moderatehover ~200 ms, or long-press on touch
conservativepointer-down

speculationRules: true is the legacy spelling of 'moderate'. The emitted rule matches every anchor in the rendered document and honours a per-link opt-out:

<a href="/pricing">Pricing</a> {/* prerendered on hover */}
<a href="/report" data-no-prerender>Report</a> {/* skipped */}

The rule’s default pattern is href_matches: '/*' — relative, so it resolves against the document’s base URL and only ever matches same-origin links. Declaring flow origins widens it: each origin is added as an absolute pattern, so a link to a sibling surface becomes a prerender candidate like any other.

cloudflare({
speculationRules: 'moderate',
flow: ['https://example.com', 'https://portal.example.com'],
})

Nothing else is authored — the anchor already in your nav is the declaration. The HTML Standard’s document-rule algorithm filters candidate links only by element type, rendered-ness and HTTP(S) scheme; there is no same-origin check, so an absolute pattern matches a cross-origin anchor that’s already on the page.

A speculate label in the ---page fence overrides the surface default for one page. eagerness is required whenever the object form is used — an unstated eagerness is exactly the ambiguity the string config removes:

---page
head: { title: 'Sign in' }
speculate: { prerender: true, eagerness: 'moderate' }
---
keydirectionwhat it does
prerenderdestinationconsents to being prerendered by a sibling — emits Supports-Loading-Mode
eagernesssourcehow eagerly this page speculates outward (required)
urlssourceabsolute URLs to warm that no anchor on the page links to

The two directions share one field deliberately: a second fence label costs a reserved name permanently, and both facets are one page’s relationship with speculative loading.

speculate: false emits no rule on that page at all — the opt-out for something heavy, where prerendering a neighbour costs more than it saves:

---page
head: { title: 'Benchmarks' }
speculate: false
---

A page with no speculate line takes the surface default. Most pages never need one.

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.

transition:NAME names an element. nav:transition names the navigation itself — same feature, different subject, and the namespace tells you which:

<Hero transition:graviton-hero /> {/* this ELEMENT is "graviton-hero" */}
<a href="/dashboard" nav:transition="auth-close"> {/* this NAVIGATION is "auth-close" */}

It reaches CSS as :active-view-transition-type(NAME), and space-separates like class for more than one. The same option travels from code as go('/dashboard', { transition: 'auth-close' }) — both write it into history.state, so the two surfaces are one mechanism.

Why it earns its place: the pseudo-element rules above can only say what animates, never which kind of navigation this is. Opening a modal over a page and closing it again are the same two routes in opposite directions; without a type, CSS cannot tell them apart.

And because :active-view-transition-type() is a document-level selector active for the transition’s duration, it can key ordinary element animations — not only view-transition pseudos:

html:active-view-transition-type(auth-close) .page-enter { animation: custom-fade-in 0.2s ease-in both; }

That matters on the surfaces where the pseudo layer is deliberately held still — the Safari opt-out above, or a page that suppresses root — because the element-level rule still runs there. The type reaches the layer that works in every browser.

Phaze passes the names through startViewTransition({ update, types }) only when a navigation actually declared one; a navigation without a type takes the plain callback form, so nothing changes for pages that don’t use it.