DSL: Binding & transitions
class:name — conditional class toggle
Section titled “class:name — conditional class toggle”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:strategy — deferred hydration
Section titled “defer:strategy — deferred hydration”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.
| Shape | Web primitive | Value |
|---|---|---|
defer:idle | requestIdleCallback (with a setTimeout fallback) | optional {{ timeout: 500 }} |
defer:visible | IntersectionObserver | optional {{ rootMargin: '200px' }} |
defer:media="(…)" | matchMedia | required — 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.__elis aDocumentFragment(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.styleis 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:KEY — per-navigation link options
Section titled “nav:KEY — per-navigation link options”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>| key | values | meaning |
|---|---|---|
nav:scroll | false · "top" · a Y offset | Where the page lands. Phaze preserves scroll position by default — see below. |
nav:transition | one or more names, space-separated like class | Names 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.
nav:transition names the navigation, transition: names an element
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.