DSL & directives
DSL stands for Domain Specific Language — the DX layer of Phaze, enabled in God Mode.
In Phaze, directives can be thought of as a “headless component” that adds reactive behavior to a DOM element. The core directives phaze:, on:, class:, bind: don’t add any additional size — use: directives can be thought of as “add-ons”. Directives stack and keep things much cleaner. Phaze has been built with performance from the ground up — directives are tree-shaken per-import, so you pay only for the use: directives you attach. For caching granularity in production, directives can also be split into their own chunk via chunkDirectives: true, bundled as phaze-directives.js.
The DSL subpath — @madenowhere/phaze/dsl
Section titled “The DSL subpath — @madenowhere/phaze/dsl”Four exports, all compile-time-aware:
| Name | Aliases | What the phaze-compiler does |
|---|---|---|
s | signal | Plain alias — no transform |
c | computed | Auto-thunks: c(expr) → c(() => expr) |
watch | effect | Auto-thunks: watch(expr) → watch(() => expr) |
phaze | (macro) | Rewrites phaze(expr) → () => expr, drops the import |
import { s, c, watch, phaze } from '@madenowhere/phaze/dsl'import { cleanup } from '@madenowhere/phaze'
export function Counter() { const count = s(0) const doubled = c(count() * 2) // auto-thunked watch(console.log(`count: ${count()}`)) // auto-thunked cleanup(() => console.log('unmounted')) // imported normally
return ( <button on:click={() => count.update(n => n + 1)}> {count} × 2 = {doubled} </button> )}Equivalent without the DSL:
import { signal, computed, effect, cleanup } from '@madenowhere/phaze'
const count = signal(0)const doubled = computed(() => count() * 2)effect(() => console.log(`count: ${count()}`))The two are transformed by the phaze-compiler to the same code. The DSL is purely an authoring convention — pick the import style your project prefers.
The phaze() macro — reactive child expressions
Section titled “The phaze() macro — reactive child expressions”Bare signal reads in JSX ({count}) are the fast path: the runtime detects the callable and wires it to a text/attribute binding directly. The moment you need to do something to the value — arithmetic, formatting, comparison — wrap in phaze():
<p>{count}</p> {/* bare — fast path */}<p>{phaze(`Total: $${total()}`)}</p> {/* template literal */}<p>{phaze(count() > 5 ? 'high' : 'low')}</p> {/* expression */}phaze(expr) rewrites to () => expr; the import drops out and the macro body tree-shakes. Same bytes as writing the arrow yourself.
The phaze-compiler also auto-wraps ternaries and && expressions in JSX child position, so {cond() ? <A/> : <B/>} is reactive without phaze(). The wrapper is for cases the auto-wrap doesn’t cover — most commonly template literals.
Subpath gotcha
Section titled “Subpath gotcha”import { signal as s } from '@madenowhere/phaze' is not the same as import { s } from '@madenowhere/phaze/dsl'. The first is a plain rename; the second activates phaze-compile‘s auto-thunking. The transform is keyed on the import path, not the local name.
JSX namespaces
Section titled “JSX namespaces”Five prefixes, each owning one job:
| Namespace | Purpose | Transformed via phaze-compiler |
|---|---|---|
phaze:attr={expr} | Reactive attribute | attr={() => expr} |
on:event={fn} | Native DOM event listener | onEvent={fn} (camelCase); CallExpression values auto-thunk to () => … |
use:name={value} | Behavior directive | name(el, () => value) post-creation |
class:name={cond} | Conditional class toggle | effect(() => el.classList.toggle('name', !!cond)) post-creation |
bind:value={signal} / bind:checked={signal} | Two-way input binding (minimal scope) | Signal pass-through + listen() post-creation |
phaze:attr — reactive attributes
Section titled “phaze:attr — reactive attributes”<article phaze:class={isFav() ? 'card fav' : 'card'} phaze:aria-label={`Card for ${name()}`} phaze:disabled={qty() <= 0}/>Transformed via phaze-compiler
<article class={() => isFav() ? 'card fav' : 'card'} aria-label={() => `Card for ${name()}`} disabled={() => qty() <= 0}/>The runtime detects the function value and wires a reactive binding. When tracked signals change, the binding re-runs the thunk and writes the result.
phaze: is only available on attributes — for reactive children, use phaze(). The two grammatical positions cover all reactive-expression cases:
<input phaze:value={name()} /> {/* attribute */}<p>{phaze(`Hello, ${name()}!`)}</p> {/* child */}on:event — native DOM events
Section titled “on:event — native DOM events”on:click is transformed by the phaze-compiler to onClick. The convention is sugar — Phaze’s JSX runtime accepts both forms — but on: matches Solid/Vue and reads more clearly when you also have phaze: and use: on the same element.
<button on:click={fn}>Send</button>Transformed via phaze-compiler
<button onClick={fn}>Send</button>CallExpression auto-thunk
Section titled “CallExpression auto-thunk”Bare call expressions as handler values are auto-thunked at compile time, so the () => ceremony disappears at every event-handler call site:
<button on:click={step.set('email')}>Email</button>Transformed via phaze-compiler
<button onClick={() => step.set('email')}>Email</button>The rule is shape-gated: only CallExpression values are wrapped. Function references, arrows, and method references pass through unchanged:
<button on:click={resend}>…</button> {/* Identifier → pass through */}<button on:click={(e) => doVerify(e)}>…</button> {/* Arrow → pass through */}<button on:click={obj.method}>…</button> {/* MemberExpression → pass through */}<button on:click={step.set('email')}>…</button> {/* CallExpression → auto-thunked */}Consistent with c / watch / phaze auto-thunking and use:spring’s auto-fuse: phaze-compile writes the boilerplate that’s identical at every call site.
A fuller example — hover + click on a state-machine signal
Section titled “A fuller example — hover + click on a state-machine signal”import { s } from '@madenowhere/phaze/match'import { spring } from '@madenowhere/photon/phaze' // use:spring
export default function HoverableDot() { const state = s<'rest' | 'hover' | 'pressed'>('rest')
const ANIM_STATES = { rest: { y: 0, opacity: 1 }, hover: { y: -4, opacity: 0.7 }, pressed: { y: 6, opacity: 0.9 }, springs: { y: { stiffness: 500, damping: 100, mass: 3 }, opacity: { stiffness: 300, damping: 60 }, }, }
return ( <div use:spring={ANIM_STATES[state()]} on:mouseenter={state.set('hover')} {/* CallExpression → auto-thunked */} on:mouseleave={state.set('rest')} {/* CallExpression → auto-thunked */} on:mousedown={state.set('pressed')} {/* CallExpression → auto-thunked */} on:mouseup={state.set('hover')} {/* CallExpression → auto-thunked */} class="size-3 rounded-full bg-black cursor-pointer" /> )}Every event handler is one expression. No () => boilerplate. The four on: lines read top-to-bottom as a natural state-transition table — exactly the four edges of the state machine.
Each state.set(...) is a CallExpression, so phaze-compile wraps it in () => state.set(...) at build time; the runtime sees a normal onMouseenter={fn} JSX attribute and wires it via the standard addEventListener path. use:spring={ANIM_STATES[state()]} picks up the per-channel physics from the nested springs key in the same record (auto-fuse — see the use:spring directive contract below). The whole interaction lives in one record + one component body.
use:directive — behavior attachments
Section titled “use:directive — behavior attachments”Attach behavior to the element after it’s created:
<input use:autofocus={shouldFocus()} />Transformed by phaze-compiler to a post-creation IIFE that calls the directive against the freshly mounted element
((__el) => (autofocus(__el, () => shouldFocus()), __el))(<input />)The directive identifier is captured from the surrounding scope — there’s no registration step. Multiple directives stack on one element with no explicit ordering:
<button use:autofocus={true} use:tooltip={"Click to send"} use:longpress={() => showOptions()} on:click={send}> Send</button>Each directive becomes one post-creation call; they don’t know about each other.
use:spring — state-driven multi-channel springs
Section titled “use:spring — state-driven multi-channel springs”Drives multiple animation channels — y, x, z, rotate, opacity, scale — from a single state-machine signal with one JSX attribute. Same shape as any use: directive, with one extra trick: phaze-compile auto-fuses the per-channel physics from a sibling springs key on the record so the JSX surface stays compact.
Pattern (a) — use:spring
Section titled “Pattern (a) — use:spring”One record holds every per-state target and the per-channel physics; the JSX surface stays one attribute. phaze-compile detects the IDENT[KEY] shape on use:spring, inspects IDENT’s static initializer for a springs key, and rewrites the JSX value to the full { to, springs } form at build time — without any change to the user-side JSX:
import { s } from '@madenowhere/phaze/match'import { spring } from '@madenowhere/photon/phaze' // use:spring
export default function Dot() { const state = s<'s1' | 's2' | 's3'>('s1')
const ANIM_STATES = { s1: { y: 0, opacity: 1 }, s2: { y: 0, opacity: 0.5 }, s3: { y: -100, opacity: 1 }, springs: { y: { stiffness: 500, damping: 100, mass: 3 }, opacity: { stiffness: 300, damping: 60 }, }, }
return ( <div use:spring={ANIM_STATES[state()]} on:mouseenter={state.set('s2')} on:mouseleave={state.set('s1')} on:click={state.set('s3')} /> )}What triggers the animation: state.set('s2') flips the state signal to 's2'; the use:spring directive is subscribed to state() via its auto-thunked value-getter (every use: directive wraps the JSX value in () => … — see Directive Anatomy), so the dependency change re-evaluates the target to ANIM_STATES.s2, and photon springs each channel from its current value to the new one. The trigger can be anything that calls state.set(…) — on:click, on:mouseenter, an intersection-observer directive, a parent-driven prop, a setTimeout — use:spring only watches the signal.
Transformed via phaze-compiler
spring(__el, () => ({ to: ANIM_STATES[state()], springs: ANIM_STATES.springs,}))The auto-fuse is shape-gated: only fires when the value is an IDENT[KEY] MemberExpression AND IDENT’s declaration is a static ObjectExpression AND that object has a top-level springs property. Anything else falls through to bare-target → photon defaults.
state() is typed 's1' | 's2' | 's3', so ANIM_STATES[state()] is the per-state target — never the springs entry (its key isn’t in the state union). Indexing is safe; springs is a sibling lookup-table the directive picks up alongside.
The springs key is optional. Omit it and photon falls back to DEFAULT_PARALLAX_SPRING for every channel. Specify it partially (e.g. only y) and unspecified channels still take the default. The default:
const DEFAULT_PARALLAX_SPRING = { stiffness: 500, damping: 100, mass: 3, restSpeed: 0.5, restDelta: 0.01, bounce: 0,}So ANIM_STATES with no springs key animates with the default physics on every channel; the auto-fuse short-circuits to bare-target and the directive call is spring(__el, () => ANIM_STATES[state()]).
Pattern (b) — refs & photonProps
Section titled “Pattern (b) — refs & photonProps”When you want per-channel PhotonValues exposed (e.g. to feed a non-DOM consumer downstream), compose spring() + photonProp() per channel in the component body. Drops the directive entirely; uses a ref to bind:
import { c } from '@madenowhere/phaze/dsl'import { s } from '@madenowhere/phaze/match'import { spring, photonProp } from '@madenowhere/photon/phaze'
export default function Dot() { const state = s<'rest' | 'hover'>('rest') const box = s<HTMLElement>()
const ANIM_STATES = { rest: { y: 0, opacity: 1 }, hover: { y: -4, opacity: 0.7 }, }
const target = c(ANIM_STATES[state()]) const y = spring(c(target().y), { stiffness: 500, damping: 100, mass: 3 }) const opacity = spring(c(target().opacity), { stiffness: 300, damping: 60 })
photonProp(box, 'style:translate-y', y, 'px') photonProp(box, 'style:opacity', opacity)
return <div ref={box} on:mouseenter={state.set('hover')} on:mouseleave={state.set('rest')} />}Each channel becomes a named PhotonValue<number>. The directive is unnecessary; photonProp writes to the DOM channel directly. Use this when:
- You need the named
PhotonValueto feed something other than the element’s own DOM channels — a GPU uniform, canvas state, a second element, aneffect()that reads the smoothed value. - You want loud failure on a missing channel rather than the directive’s silent fallback to
DEFAULT_PARALLAX_SPRING. - You want per-channel physics tuned at the
spring(…)call site, alongside the value it springs, instead of in a siblingspringsrecord.
Choosing between forms
Section titled “Choosing between forms”| Use case | Form |
|---|---|
| State-driven animation, default physics fine | (a) use:spring (omit the springs key — falls back to DEFAULT_PARALLAX_SPRING) |
| State-driven animation, custom per-channel physics, all data in one record | (a) use:spring + nested springs key |
Need named PhotonValue per channel, non-DOM consumers, or loud failure on missing channels | (b) refs & photonProps |
Both forms compose with the rest of phaze — on:mouseenter / on:mouseleave / on:click etc. drive the same state signal regardless of which spring shape consumes it.
use:warp — tilt + hover-scale
Section titled “use:warp — tilt + hover-scale”Mouse-tracking tilt + hover-scale directive. Writes the standalone CSS rotate and scale properties (Baseline 2023) — never style.transform — so it composes cleanly with use:spring, use:parallax, photon transforms, or any other system writing transform. One singleton deviceorientation listener and one singleton mousemove listener serve the whole page, regardless of how many warped elements exist.
Pattern (a) — use:warp
Section titled “Pattern (a) — use:warp”The minimal form. Pass any ApplyWarpOptions object; graviton attaches mouse + gyro listeners on construction, writes rotate and scale on each event, eases back on mouse-leave, auto-disposes on scope teardown:
import { warp } from '@madenowhere/graviton/phaze' // use:warp
const WARP = { maxAngleX: 10, maxAngleY: 10, scale: 1.03, transitionSpeed: 300, scaleSpeed: 200, scaleEasing: 'cubic-bezier(0,.5,.3,1.4)',}
<div use:warp={WARP} class="…" />phaze-compile rewrites use:warp={WARP} to warp(__el, () => WARP) — same IIFE shape as every other use: directive.
Pattern (b) — use:warp with the scene-bridge pivot
Section titled “Pattern (b) — use:warp with the scene-bridge pivot”Graviton’s cross-island scene bridge publishes a per-frame pivot vector — it syncs the 3D perspective with Fabric’s 3D Scene. Plug that into use:warp’s pivot option and the warped element’s local cursor tilt rides on top of the published camera tilt — DOM cards tilt with the scene even when the cursor isn’t moving:
import { warp, useGravitonPivot, GravitonScene } from '@madenowhere/graviton/phaze'
<GravitonScene> <div use:warp={{ maxAngleX: 10, scale: 1.03, pivot: useGravitonPivot() }}> {/* tilts with cursor + gyro + scene camera, all summed */} </div></GravitonScene>useGravitonPivot({ strength }) returns a getter the directive polls each frame. When Fabric’s 3D engine pauses (canvas off-screen), the bridge fades to a singleton-cursor pivot over a 400ms window so warped elements keep responding.
Pattern (c) — warp(target, opts) function form
Section titled “Pattern (c) — warp(target, opts) function form”When you want to declare the behavior in the component body next to other refs (Direction B from the directive contract), call warp directly with a phaze signal of HTMLElement. Same semantics as the directive form — auto-disposes on scope teardown:
import { s } from '@madenowhere/phaze/dsl'import { warp } from '@madenowhere/graviton/phaze'
const card = s<HTMLElement>()warp(card, { maxAngleX: 10, scale: 1.03 })return <div ref={card} class="…" />Options summary
Section titled “Options summary”| Option | Type | Default | Notes |
|---|---|---|---|
maxAngleX / maxAngleY | number | 0 | Max tilt around each axis (degrees). |
scale | number | 1 | Hover scale factor. |
transitionSpeed / scaleSpeed | number (ms) | 400 / 100 | Ease-back timings. Rotation has no transition during hover (instant cursor tracking). |
transitionEasing / scaleEasing | string | cubic-bezier(…) | CSS easings. |
perspective | number (px) | 1000 | Written on the parent. Skipped if a parent already has perspective. |
gyroscope / gyroscopeSensitivity | boolean / number | true / 1 | Mobile gyro listening + multiplier. |
enabled / hover | boolean | true / true | Master toggle / local-cursor-and-gyro gate. hover: false + a pivot getter = pivot-only mode. |
pivot | (() => { x; y }) | null | null | External pivot getter (typically useGravitonPivot()). Added to the local hover tilt each frame. |
reverse | boolean | false | Invert tilt direction. |
Full reference + the framework-free applyWarp(node, opts) → dispose form (for non-Phaze consumers), the cross-island scene-bridge producer/consumer API (setGravitonPivot, setGravitonScene, onGravitonScene, computePerspectiveStyles), and the iOS <OrientationPrompt> permission flow live in the graviton README.
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](/phaze-compiler/)
((__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](/phaze-compiler/)
((__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).
Combining everything
Section titled “Combining everything”All five namespaces compose on a single element. Post-creation operations (use:, class:, bind:) run in source order inside one IIFE; phaze: and on: rewrite in place to standard attributes:
<input type="text" class="input" use:autofocus={true} class:invalid={!valid()} bind:value={text} on:focus={track}/>Transformed via phaze-compiler (approximate)
((__el) => ( autofocus(__el, () => true), effect(() => __el.classList.toggle('invalid', !valid())), listen(__el, 'input', (e) => text.set(e.currentTarget.value)), __el))(<input type="text" class="input" value={text} onFocus={track} />)Directive Anatomy
Section titled “Directive Anatomy”A directive is a plain function:
type Directive<T> = (el: HTMLElement, value: () => T) => void | (() => void)- First arg: the DOM element the directive is attached to.
- Second arg: a thunk returning the value. The phaze-compiler always wraps in
() =>, even if the caller passed a static value, so directives can read reactively or pass through. - Return: void, or an optional cleanup function. Idiomatic Phaze prefers
cleanup()from the main entry — it composes with the parent computation tree.
Component vs directive
Section titled “Component vs directive”| Component | Directive | |
|---|---|---|
| Signature | (props) => Node | (el, value: () => T) => void |
| Returns | DOM tree | nothing or cleanup |
| Creates DOM at JSX position | yes | no |
| Used as | <Component /> | use:directive={value} |
| Job | Structure (what UI exists) | Behavior (what existing UI does) |
| Composition | Nesting (each adds a wrapper) | Stacking (multiple on one element) |
Components contribute DOM to the JSX tree where they’re written. Directives attach behavior to an existing element — and that behavior may include creating, moving, or destroying DOM elsewhere, just not at the call site.
A tooltip is a directive even though it appends a <div> to document.body — because the DOM it creates is out-of-band from the JSX position.
Imperative vs reactive inside a directive
Section titled “Imperative vs reactive inside a directive”Directives can use Phaze’s reactive primitives but should only when reactivity earns its place. For straightforward DOM work, imperative code is clearer.
Imperative wins when the directive sets up listeners that run as events fire, internal state is a simple let, DOM mutation is direct, and there’s no derived-state fan-out.
Reactive wins when behavior depends on multiple inputs combining into derived state, multiple bindings inside the directive react to shared state, or animation states cascade.
Anti-pattern: reaching for signals inside a directive because the framework provides them, when imperative code does the job more directly.
Naming conventions
Section titled “Naming conventions”use:directiveNamein JSX — camelCase identifier- Implementation file:
directive-name.ts— kebab-case - Export: named, lowercase camelCase
export const clickOutside = (el: HTMLElement, value: () => () => void) => { /* … */ }
// usage<div use:clickOutside={onClose} />@madenowhere/phaze-directives — core
Section titled “@madenowhere/phaze-directives — core”A small package of directives that pair with the runtime. Each is ~20–30 lines.
| Name | Value | Behavior |
|---|---|---|
autofocus | boolean (or signal) | Focuses the element while truthy |
tooltip | string | Shows a positioned tooltip on hover/focus |
longpress | () => void | Fires the callback after a 500ms hold |
clickOutside | () => void | Fires the callback on clicks outside the element |
import { autofocus, tooltip, longpress, clickOutside } from '@madenowhere/phaze-directives'import { s } from '@madenowhere/phaze/dsl'
const open = s(true)
<menu use:clickOutside={() => open.set(false)}> <input use:autofocus={open} /> <button use:tooltip={"Reset (long-press)"} use:longpress={() => app.reset()} on:click={app.confirm} > Done </button></menu>Heavier helpers live on subpaths of the same package, so they don’t ride along when you only want the built-ins above:
@madenowhere/phaze-directives/in-view— theinViewdirective (use:inView={signal}for viewport-intersection state) and theuseInView<T>()hook ({ ref, inView, entry }tuple), both over oneIntersectionObserver.@madenowhere/phaze-directives/scramble—useScramble(props)→{ ref, replay }, a text-scramble animation (prefers-reduced-motion-aware).
(@madenowhere/phaze-in-view and @madenowhere/phaze-scramble were standalone packages before; they’re the /in-view and /scramble subpaths now — the old packages are deprecated on npm.)
Writing your own
Section titled “Writing your own”A directive is a plain function. No registration step:
import { listen } from '@madenowhere/phaze'
export const focusRing = (el: HTMLElement, value: () => string) => { const apply = () => { el.style.outline = `2px solid ${value()}` } apply() listen(el, 'focus', apply) listen(el, 'blur', () => { el.style.outline = '' })}
// usageimport { focusRing } from './directives/focus-ring'<input use:focusRing={"#03e6ff"} />listen() registers via the active computation’s AbortController, so the listener is removed automatically when the element unmounts. No cleanup() needed for that case.
Type-safe directive props
Section titled “Type-safe directive props”Directive packages augment the global JSX.Directives registry, so use:name={value} is type-checked against the directive’s value type:
// inside @madenowhere/phaze-directivesdeclare global { namespace JSX { interface Directives { autofocus: boolean tooltip: string longpress: () => void clickOutside: () => void } }}<input use:autofocus={42} /> errors at the call site (Type 'number' is not assignable to type 'boolean'). Importing the package activates the augmentation; no extra setup.
Diagnostics
Section titled “Diagnostics”phaze-compile and the runtime emit named, actionable diagnostics for the common footguns. Every runtime warning is wrapped in import.meta.env.DEV (or import.meta.env.SSR for server-only ones), so production bundles strip them entirely — no shipped-byte cost.
Compile-time errors — phaze-compile
Section titled “Compile-time errors — ”| Diagnostic | Signature | Trigger |
|---|---|---|
use:NAME unbound | use:foo: `foo` is not in scope. Import the directive — e.g. `import { foo } from '…'` — so phaze-compile can emit `foo(__el, () => value)`. If you meant a native DOM event, use `on:foo` instead. | use:NAME references an identifier that path.scope.getBinding(NAME) can’t resolve. Catches missing imports + the common use:-vs-on: namespace mistake. |
phaze:onXxx | phaze:onClick is not valid — `phaze:` is for reactive *attributes*, not event handlers. Use `on:click={fn}` for native DOM events… | phaze:onXxx={fn} (where local starts with on+letter). The naïve compile output would wire a getter that returns the function value — the browser never sees the handler. Throws at compile time. |
bind:value on a non-text input | bind:value on <input type="number"> is not supported. Use the manual form with explicit coercion: `<input type="number" value={signal} on:input={(e) => signal.set(+e.currentTarget.value)} />` | Existing — bind:value only supports text-like inputs and <textarea>. Error includes a hand-rolled equivalent with the right coercion for the input’s type. |
bind:checked on a non-checkbox | bind:checked is only supported on <input type="checkbox"> … | Existing — bind:checked is checkbox-only. |
bind:Xother | bind:disabled is not supported. Phaze's bind: namespace handles bind:value (text inputs, textarea) and bind:checked (checkboxes) only. | Existing — bind: is intentionally minimal-scope. |
for:NAME on non-<For> | for:item is only valid on the <For> component — `for:NAME={signal}` declares the per-item binding name. On other components, write the renderer explicitly: `<C children={(item) => …}/>` or `<C>{(item) => …}</C>`. | for:NAME namespace appeared on a component that isn’t named For. Each-binding-via-namespace is For-specific; other components don’t use the runtime contract that consumes it. |
for:NAME missing value or child | for:item requires an expression value: `for:item={signal}`. / <For for:item={…}> has no child to render — provide an inline JSX element: `<For for:item={signal}><X/></For>`. | for:NAME was written valueless (<For for:item>) or with no body. Two narrowly-targeted messages instead of a generic “missing input” so the fix is in the error text. |
All compile errors point at the source location and include a fix in the message. They surface during build (pnpm build) and during dev-server transforms.
Runtime warnings — DEV-gated, dead-stripped in production
Section titled “Runtime warnings — DEV-gated, dead-stripped in production”| Diagnostic | Signature | Trigger | Where |
|---|---|---|---|
on: handler factory | [phaze on:] handler returned a function — did you mean to invoke a factory? Bind the factory result to a const first: const handler = factory(args); on:click={handler} | on:click={callExpression} auto-thunked, the call returned a function (handler-factory pattern). Fires on the click that “did nothing”. | Auto-thunked handler body (emitted by phaze-compile). |
| Dynamic-child shape mismatch | [phaze] dynamic-child shape mismatch: peek=DIV apply=SPAN. Likely cause: a JSX-shape-changing ternary at a {…}-child position … | The untrack(fn) peek of a {() => …} JSX child returns one shape; the first effect-tracked call returns a different one. The hazard the appendDynamicChild first-run pattern was designed to handle but which the user usually didn’t mean. Fix: class:hidden={cond} toggles instead of JSX-shape ternaries. | appendDynamicChild in @madenowhere/phaze’s jsx-runtime. |
SSR-only diagnostics — server bundle only, stripped from client
Section titled “SSR-only diagnostics — server bundle only, stripped from client”| Diagnostic | Signature | Trigger | Where |
|---|---|---|---|
signal.async() at SSR | [phaze SSR] signal.async() called during SSR — the loader Promise will not resolve before the response is sent. The SSR-rendered HTML shows .pending() = true; the loader fires AGAIN on the client at hydrate-time, doubling the request. … | signal.async() invoked during a phaze-render-to-string render. Fires once per SSR process. Fix: gate with if (!import.meta.env.SSR) or fetch via Astro Action / server endpoint and pass via props. | @madenowhere/phaze signal.ts. |
| SSR render failure with augmented error | [phaze SSR] render failed: <original message>. Common causes: … | A use: directive body / component scope throws synchronously during phaze-render-to-string. Wraps the original Error via .cause (so stack traces still point at the offending line) and prepends the four common root-causes (unguarded window/document access, browser-only module side effects, missing directive import, etc.). | @madenowhere/phaze-render-to-string. |
| Empty-output probe | [phaze SSR] render() returned an empty body. Possible causes: 1) root component returned null/false/undefined; 2) workerd silent-abort during JSX construction — known to fire when a post-creation IIFE executes code workerd's runner-worker rejects during the streamed response. … | phaze-render-to-string produced empty innerHTML despite no thrown error. Catches the workerd silent-abort signature without leaving the developer staring at a blank page. | @madenowhere/phaze-render-to-string. |
Why this matters for the bundle budget
Section titled “Why this matters for the bundle budget”Every runtime warning is gated by if (import.meta.env.DEV && …) console.warn(…). Vite (and any constants-folding bundler) replaces import.meta.env.DEV with false in production builds, the minifier dead-strips the if (false && …) block, then strips the unused locals — leaving the production output byte-identical to the hand-written form without the warning. Same for import.meta.env.SSR: client bundles strip the SSR-only warnings; server bundles keep them. Verified end-to-end via grep on the production dist/.
Bundle impact
Section titled “Bundle impact”Every transform on this page is compile-time. phaze-compile ships zero bytes to the runtime; the macros and namespaces translate to plain props or function calls before bundling.
| Source | Compiled | Delta vs hand-written |
|---|---|---|
phaze:class={expr} | class={() => expr} | 0 |
class={phaze(expr)} | class={() => expr} | 0 |
{phaze(expr)} | {() => expr} | 0 |
on:click={fn} | onClick={fn} | 0 |
use:dir={value} | dir(el, () => value) post-creation | slightly less than an equivalent ref callback |
class:name={cond} | effect(() => el.classList.toggle('name', !!cond)) post-creation | 0 (uses existing effect + native classList.toggle) |
bind:value={signal} | signal pass-through + listen(el, 'input', …) post-creation | 0 (uses existing listen) |
bind:checked={signal} | signal pass-through + listen(el, 'change', …) post-creation | 0 (uses existing listen) |
s / c / watch aliases | signal / computed / effect calls | 0 |
What ships: the Phaze runtime (paid once), your component code, and the directive function bodies you actually use (tree-shaken otherwise). What never ships: phaze-compile, the phaze macro body, namespace recognition logic.
For class: and bind: specifically, the phaze-compiler auto-injects the effect / listen imports from @madenowhere/phaze if they aren’t already present. Existing imports are merged rather than duplicated.
The phaze:/on:/use:/class:/bind: namespaces and the DSL auto-thunking require phaze-compile in your build pipeline.
- Astro:
@madenowhere/phaze-astrowires the phaze-compiler automatically. No extra config. - Vite (no Astro): add the
@madenowhere/vite-plugin-phazeplugin, which includes the phaze-compiler. - Other bundlers: import the Babel plugin from
@madenowhere/phaze-compiledirectly.
If you only want the runtime — no DSL, no namespaces — @madenowhere/phaze alone works with any tooling that supports the standard JSX automatic transform. See Setup.