DSL: Directives
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 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 setTimeout — use: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()]).
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 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
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.
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.