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