Skip to content

DSL: Directives

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 CARD_ANIM = {
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={CARD_ANIM[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 CARD_ANIM.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: CARD_ANIM[state()],
springs: CARD_ANIM.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 CARD_ANIM[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 CARD_ANIM 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, () => CARD_ANIM[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 CARD_ANIM = {
rest: { y: 0, opacity: 1 },
hover: { y: -4, opacity: 0.7 },
}
const target = c(CARD_ANIM[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.

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.