Skip to content

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:

NameAliasesWhat the phaze-compiler does
ssignalPlain alias — no transform
ccomputedAuto-thunks: c(expr)c(() => expr)
watcheffectAuto-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.

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.

Five prefixes, each owning one job:

NamespacePurposeTransformed via phaze-compiler
phaze:attr={expr}Reactive attributeattr={() => expr}
on:event={fn}Native DOM event listeneronEvent={fn} (camelCase); CallExpression values auto-thunk to () => …
use:name={value}Behavior directivename(el, () => value) post-creation
class:name={cond}Conditional class toggleeffect(() => 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
<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: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>

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.

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.

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 setTimeoutuse: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()]).

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 PhotonValue to feed something other than the element’s own DOM channels — a GPU uniform, canvas state, a second element, an effect() 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 sibling springs record.
Use caseForm
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.

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.

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="" />
OptionTypeDefaultNotes
maxAngleX / maxAngleYnumber0Max tilt around each axis (degrees).
scalenumber1Hover scale factor.
transitionSpeed / scaleSpeednumber (ms)400 / 100Ease-back timings. Rotation has no transition during hover (instant cursor tracking).
transitionEasing / scaleEasingstringcubic-bezier(…)CSS easings.
perspectivenumber (px)1000Written on the parent. Skipped if a parent already has perspective.
gyroscope / gyroscopeSensitivityboolean / numbertrue / 1Mobile gyro listening + multiplier.
enabled / hoverbooleantrue / trueMaster toggle / local-cursor-and-gyro gate. hover: false + a pivot getter = pivot-only mode.
pivot(() => { x; y }) | nullnullExternal pivot getter (typically useGravitonPivot()). Added to the local hover tilt each frame.
reversebooleanfalseInvert 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.

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

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} />)

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.
ComponentDirective
Signature(props) => Node(el, value: () => T) => void
ReturnsDOM treenothing or cleanup
Creates DOM at JSX positionyesno
Used as<Component />use:directive={value}
JobStructure (what UI exists)Behavior (what existing UI does)
CompositionNesting (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.

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.

  • use:directiveName in JSX — camelCase identifier
  • Implementation file: directive-name.ts — kebab-case
  • Export: named, lowercase camelCase
directives/click-outside.ts
export const clickOutside = (el: HTMLElement, value: () => () => void) => { /* … */ }
// usage
<div use:clickOutside={onClose} />

A small package of directives that pair with the runtime. Each is ~20–30 lines.

NameValueBehavior
autofocusboolean (or signal)Focuses the element while truthy
tooltipstringShows a positioned tooltip on hover/focus
longpress() => voidFires the callback after a 500ms hold
clickOutside() => voidFires 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-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.)

A directive is a plain function. No registration step:

src/directives/focus-ring.ts
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 = '' })
}
// usage
import { 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.

Directive packages augment the global JSX.Directives registry, so use:name={value} is type-checked against the directive’s value type:

// inside @madenowhere/phaze-directives
declare 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.

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 — ”
DiagnosticSignatureTrigger
use:NAME unbounduse: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:onXxxphaze: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 inputbind: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-checkboxbind:checked is only supported on <input type="checkbox"> …Existing — bind:checked is checkbox-only.
bind:Xotherbind: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 childfor: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”
DiagnosticSignatureTriggerWhere
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”
DiagnosticSignatureTriggerWhere
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.

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

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.

SourceCompiledDelta 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-creationslightly less than an equivalent ref callback
class:name={cond}effect(() => el.classList.toggle('name', !!cond)) post-creation0 (uses existing effect + native classList.toggle)
bind:value={signal}signal pass-through + listen(el, 'input', …) post-creation0 (uses existing listen)
bind:checked={signal}signal pass-through + listen(el, 'change', …) post-creation0 (uses existing listen)
s / c / watch aliasessignal / computed / effect calls0

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.

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.