Skip to content

DSL: Binding & transitions

Additively toggle a single class based on the truthiness of an expression. The static class="…" attribute sets the base; each class: directive flips its own class on top, reactively, when tracked signals change.

<article
class="card"
class:fav={isFav()}
class:expanded={expanded()}
class:warn={lowStock()}
class:loading={isLoading()}
>
Transformed via phaze-compiler
import { effect } from '@madenowhere/phaze' // auto-injected by the [phaze-compiler](/tooling/phaze-compile/)
((__el) => (
effect(() => __el.classList.toggle('fav', !!isFav())),
effect(() => __el.classList.toggle('expanded', !!expanded())),
effect(() => __el.classList.toggle('warn', !!lowStock())),
effect(() => __el.classList.toggle('loading', !!isLoading())),
__el
))(<article class="card" />)

Each class: becomes one effect — independent, reactive, source-ordered. Kebab-case class names work verbatim (class:is-loading={…} toggles is-loading). A valueless class:active means “always on” (compiled to classList.toggle('active', true)).

The phaze-compiler auto-imports effect from @madenowhere/phaze when any class: directive appears in the file, so you don’t need to remember the import.

bind:value / bind:checked — two-way input binding (minimal scope)

Section titled “bind:value / bind:checked — two-way input binding (minimal scope)”
<input type="text" bind:value={name} />
<textarea bind:value={bio} />
<input type="checkbox" bind:checked={remember} />

The signal flows both ways: signal changes update the input; user input updates the signal.

bind:value transformed by phaze-compiler
import { listen } from '@madenowhere/phaze' // auto-injected by the [phaze-compiler](/tooling/phaze-compile/)
((__el) => (
listen(__el, 'input', (e) => name.set(e.currentTarget.value)),
__el
))(<input type="text" value={name} />)
bind:checked transformed by phaze-compiler
((__el) => (
listen(__el, 'change', (e) => remember.set(e.currentTarget.checked)),
__el
))(<input type="checkbox" checked={remember} />)

listen() registers via the active scope’s AbortController, so the input listener is removed automatically when the element unmounts. No cleanup() needed.

defer: picks when an element’s subtree hydrates, instead of paying it all at page load. It’s the timing axis — orthogonal to ssr={false} (the server-render axis). The HTML still ships from byte one (LCP / CLS unaffected); only when the subtree wires up its reactivity is deferred — spreading hydration work off the critical path (TBT / INP win). Eager hydration is the default, so there’s no defer:load — you only opt out of eager.

ShapeWeb primitiveValue
defer:idlerequestIdleCallback (with a setTimeout fallback)optional {{ timeout: 500 }}
defer:visibleIntersectionObserveroptional {{ rootMargin: '200px' }}
defer:media="(…)"matchMediarequired — the media-query string is the value
<HeavyChart defer:idle /> {/* hydrate when the browser's idle */}
<BelowFold defer:visible={{ rootMargin: '300px' }} /> {/* hydrate as it scrolls into view */}
<DesktopOnly defer:media="(min-width: 1024px)" /> {/* hydrate only on wide viewports */}
Transformed via phaze-compiler
import { deferHydrate } from '@madenowhere/phaze/defer' // auto-injected
{deferHydrate('visible', { rootMargin: '300px' }, () => <BelowFold />)}

deferHydrate returns the same handle shape staticSubtree uses, so the JSX runtime resolves it after the parent’s hydration frame is on the stack — no JSX-runtime change. At resolve time it renders a <phaze-defer style="display:contents"> wrapper, skips it during the main hydrate pass (the subtree stays inert), and arms the trigger; when the trigger fires, hydrate() wires the subtree — adopting the SSR’d DOM in place, or rendering fresh if there was none.

defer: composes with ssr={false}: <Heavy ssr={false} defer:idle/> means “no server HTML and mount lazily on the client.” Because hydrate() on an empty container falls back to a fresh render(), the same trigger path covers both an SSR’d subtree (deferred hydration) and a client-only one (deferred mount). defer:visible needs SSR’d content to observe — on a client-only empty wrapper there’s no box for the observer, so reach for defer:idle there.

transition:NAME — view-transition group naming

Section titled “transition:NAME — view-transition group naming”

transition:NAME names an element’s view-transition group — the element-side handle that CSS ::view-transition-old/new(NAME) rules target. The name goes in the key (like class:NAME); the attribute takes no value. phaze-compile rewrites it to a static view-transition-name style at build time:

<h1 transition:hero>Predict the peak</h1>
// ↓ phaze-compile
<h1 style={{ viewTransitionName: 'hero' }}>Predict the peak</h1>

The animation lives in CSS, not on the element — transition:NAME only names the group:

::view-transition-old(hero) { animation: fade-out 0.2s ease-out both; } /* exit */
::view-transition-new(hero) { animation: fade-in 0.2s ease-in both; } /* enter */
/* ::view-transition-group(hero) — position/size morph, if the same name is on both pages */

It’s pure compile-time sugar for view-transition-name — zero phaze bytes, SSR-serialised into the HTML so the group name exists before any JS. The name string is the whole contract: it must match between the directive and the ::view-transition-*(NAME) rules. Pairs with the Phaze Router’s startViewTransition-wrapped swaps, but the property is browser-universal — useful for any View-Transitions setup.

On a component, there’s no DOM node at the call site, so the compiler names the node the component returns (its root) via a post-creation op — no boilerplate in the component:

// page — names the component's transition group from the outside
<Hero transition:graviton-hero />
// ↓ phaze-compile — Fragment-safe emit so both component shapes work:
((__el) => (
((t) => t && t.setProperty('view-transition-name', 'graviton-hero'))(
__el.style || (__el.firstElementChild && __el.firstElementChild.style)
),
__el
))(<Hero />)
// Hero stays clean — no style prop, no forwarding:
export default function Hero() {
return (
<>
<h1>Predict the peak…</h1>
<div use:parallax={…} />
</>
)
}

The two component shapes are handled:

  • Fragment-rooted (return <>…</>) — the Phaze convention. __el is a DocumentFragment (no .style), so the name falls through to __el.firstElementChild.style — landing on the first element child (<h1> above). That’s the natural “named root” for a multi-element component: the headline is the visual entry point; ::view-transition-old/new(graviton-hero) choreographs its enter / exit.
  • Single-Element-rooted (return <section>…</section>) — __el.style is defined → the name lands on the wrapping element, naming the whole component as one group.

This mirrors Astro’s transition:name on a component (applies to a single root) but composes with Phaze’s Fragment-first idiom; if the component returns purely text or null, the op silently no-ops (no crash). It’s the only transition: form with a (tiny) runtime — a one-time setProperty on the resolved node; host elements stay pure compile-time.

To name a specific inner element instead, put transition:NAME directly on that element inside the component (the host-element form above).

nav: puts options on the link that causes a navigation, for the two things a navigation can decide that the destination can’t: where it lands and what kind of transition it is.

<a href="/articles" nav:scroll="top">Articles</a>
<a href="/dashboard" nav:transition="auth-close">Continue</a>
<a href="/x" nav:scroll="top" nav:transition="fade">Both — directives stack</a>
keyvaluesmeaning
nav:scrollfalse · "top" · a Y offsetWhere the page lands. Phaze preserves scroll position by default — see below.
nav:transitionone or more names, space-separated like classNames the navigation for CSS: :active-view-transition-type(NAME).
nav:prefetch{false}Don’t warm this link on viewport entry.
nav:prerender{false}Exclude this link from the Speculation Rules prerender.

The last two replace the bare data-no-prefetch / data-no-prerender attributes, so per-link navigation options are one vocabulary rather than a namespace plus two stray data attributes. They lower to exactly those attributes, which the existing readers already look for — nothing new runs.

They’re literal-boolean only, and the compiler says so rather than letting a signal look live: prefetching is decided once, when the link enters the viewport, and the prerender rule is a selector baked into the emitted script at build. A reactive value would have nothing left to affect. Opting in is the default, so {true} emits nothing at all.

Scroll is preserved by default — this is the opt-in

Section titled “Scroll is preserved by default — this is the opt-in”

Most routers scroll to the top on every navigation. Phaze doesn’t, and that isn’t an omission: the router keeps the layout instance mounted across a nav (reference-equality on module.layout), so chrome, focus and in-flight animation survive. Yanking scroll to the top would contradict the continuity that buys.

So nav:scroll is how a link asks for a reset, and false is the default said out loud — the same way restricted: false states a public route explicitly rather than relying on the reader knowing the default.

<a href="/articles" nav:scroll="top">Back to the top of the list</a>
<a href="/articles" nav:scroll={240}>Land at a known offset</a>
<a href="/gallery" nav:scroll={false}>Keep position (the default, stated)</a>

The option is consumed once, like Flow.in — it describes the navigation that was issued, not the history entry. Going Back doesn’t replay it, so it never fights the browser’s own scroll restoration.

Section titled “nav:transition names the navigation, transition: names an element”

Two directives, 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" */}

The element name reaches CSS as ::view-transition-old/new(graviton-hero); the navigation name reaches it as :active-view-transition-type(auth-close), which is a document-level selector active for the transition’s duration — so it can key ordinary element animations, not only view-transition pseudos:

html:active-view-transition-type(auth-close) .page-enter { animation: … }

That matters where the pseudo layer is suppressed (Safari, or any surface that holds ::view-transition-* still on purpose): the type still matches, so the entrance can be chosen per navigation kind without moving motion back into the pseudos.

nav: belongs on the element that navigates. On a component it’s a compile error — a component has no href for the router to read — and an unknown key is rejected with the valid ones listed.