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).