reframe eDSL guide
You write a motion-graphics scene as declarative data using the reframe TypeScript eDSL. Your output is a single.ts file that default-exports a
scene({...}) call. Everything imports from @reframe/core.
See examples/scenes/… pointers below refer to the GitHub repo
(github.com/kiyeonjeon21/reframe), not the installed npm package — this guide is
self-contained; you don’t need them to write a scene.
Nodes
Factories return plain data. Every node needs a uniqueid.
rect({ id, x, y, width, height, fill?, stroke?, strokeWidth?, radius?, opacity?, rotation?, scale?, anchor? })ellipse({ id, x, y, width, height, fill?, stroke?, strokeWidth?, ... })line({ id, x1, y1, x2, y2, stroke, strokeWidth?, opacity?, progress? })—progress0..1 draws the line on (1 = full line).text({ id, x, y, content, contentDecimals?, contentThousands?, prefix?, suffix?, fontFamily, fontSize, fontWeight?, fill?, letterSpacing?, ... })—contentmay be a number; numeric content interpolates (count-up) and renders viatoFixed(contentDecimals ?? 0). For a “8.2”-style label, setcontentDecimals: 1;contentThousands: truegroups the integer (35,786).prefix/suffixwrap the value so a count-up reads$2.4Mor+32%from ONE node ({ content: 2.4, contentDecimals: 1, prefix: "$", suffix: "M" }) — don’t hand-position separate$/%nodes.path({ id, d, x, y, fill?, stroke?, strokeWidth?, progress?, originX?, originY?, opacity?, rotation?, scale?, anchor? })— a true vector shape from an SVG pathdstring (crisp at any zoom; recolour by animatingfill/stroke).progress0..1 draws the stroke OUTLINE on (animate 0→1 for a self-drawing logo).originX/originYis the local pivot — set it to the art’s centre (e.g. the viewBox centre) soscale/rotationhappen about the middle.dis drawn in its own coords;x/yplace that pivot. Classic logo reveal: a stroke path drawing on, then a fill path fading in over it.dis animatable (shape morph):tween(id, { d: otherShape }, …)morphs the path vertex-by-vertex (the Lottie-style shape tween) when bothdstrings share the same command sequence and arg counts — author the two poses with the same structure (e.g. both 4-cubic ovals). Arcs (A) can’t morph (their 0/1 flags aren’t interpolable) and incompatible shapes snap at the midpoint; build morph targets fromM/L/C/Q/Zonly.image({ id, src, x, y, width, height, fit?, opacity?, rotation?, scale?, anchor? })—srcis a file path, absolute or relative to the scene file (png/jpg/webp).fitcontrols how it maps intowidth×height:"fill"(default) stretches;"cover"crops to fill the box at the image’s natural aspect, centered (no distortion — drop in any-aspect photos).srcswitches discretely (no crossfade) — for hard-cut frame sequences stack image nodes and step theiropacity; for a dissolve, crossfade two nodes’ opacity.group({ id, x, y, opacity?, rotation?, scale?, anchor? }, children)— children’s coordinates are relative to the group; group opacity/transform multiply down.
anchor controls placement and scale/rotation origin:
"top-left" (default) | "top-center" | "top-right" | "center-left" |
"center" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right".
Example: a bar that grows upward = anchor: "bottom-left" + animate height.
Text alignment is anchor, not a separate align prop: the anchor’s horizontal
half sets the text align — "…-left" left-aligns, "…-center"/"center" centers,
"…-right" right-aligns (a right-aligned wordmark in a corner = anchor: "bottom-right").
Font: use fontFamily: "Inter" (weights 400/700/800 are available).
Layout helpers (evenly spacing things)
Positions are absolute pixels. For a row of cards or a grid of tiles, use the layout helpers instead of hand-rolling the column math — they return coordinates you spread intox/y:
column is row for the y axis.
Charts/widgets in a panel — derive geometry from the box, and clip it. Don’t
hand-pick a pixels-per-unit scale (bars routinely overflow the panel that way).
Define the panel rect ONCE, then size from it — bar height (v/max) · innerH, x via
row(...) across the panel width — so a tall value can’t exceed the box. As a safety
net, wrap the chart in a clipped group so nothing can ever punch out the panel:
group({ clip: { kind: "rect", x, y, width, height, radius } }, [ ...bars ]). See
examples/scenes/annual-report.ts (and cameraFit above to frame the panel).
States: declare looks, not motion
Base props on nodes describe the finished design. A state is a sparse override — only the props that differ:to("shown", { duration, ease, stagger?, filter? }) synthesizes a transition
from each node’s current value to the state’s value. stagger: 0.1 offsets
the affected nodes 0.1s apart in declaration order. filter: ["a", "b"]
restricts the transition to those nodes. States are plain objects — generate
them with normal TS (Object.fromEntries, .map) for data-driven scenes.
Timeline: compose time
seq(...steps)— one after another.par(...steps)— all start together; ends when the longest ends.stagger(interval, ...steps)— likeparbut each child startsintervallater.to(stateName, opts)— transition into a named state (see above).tween(nodeId, { prop: value, ... }, { duration, ease })— low-level escape hatch for one node. Colors ("#rrggbb") interpolate; numbers interpolate.motionPath(nodeId, [[x,y], ...], { duration, ease, curviness?, autoRotate?, rotateOffset?, closed? })— drive a node’sx/yalong a smooth Catmull-Rom curve through the waypoints (parent-space coords).autoRotate: truebanks the node along the path tangent (rotateOffsetdegrees if the art faces “up”, e.g.-90). The node HOLDS at the final point after the path finishes (a positioning move, not a one-shot), so a latertweencan chain from there. Use it for swoops/arcs/orbits — straighttweens on x and y can’t curve.closed: trueloops the waypoints (orbit).curvinessshapes the path:1smooth (default),0sharp corners,>1loopier.wait(seconds, label?)— hold; the optionallabelnames the hold so audio cues and overlay retiming can address it.beat(name, opts, children)— a named, retimable, reorderable span (the unit humans/AI revise; itsnameis a stable overlay address).opts:parallel,at(absolute start — a NUMBER, or a label string to anchor to),gap,scale/duration(time-stretch),order(reorder within aseq),nodes. Label anchor:beat("caption", { at: "shot-2" }, [...])starts the beat at theshot-2label’s time (withgapas the offset), so a title/lower-third/ caption laid over a montage stays locked to its shot when the cut is retimed (via an overlay or AI regen) — the same retime-survivalaudio.cuesget. Put anchored beats in aparbranch (an overlay layer), not inside a sequential flow. Seeexamples/scenes/media-story.ts.
linear, easeIn/Out/InOutQuad, easeIn/Out/InOutCubic,
easeIn/Out/InOutQuart, easeIn/Out/InOutExpo, or { cubicBezier: [x1,y1,x2,y2] }.
Decelerating entrances = easeOut*, accelerating exits = easeIn*.
Expressive eases for a premium feel: easeIn/Out/InOutBack (overshoots past the
target then settles — a pop/snap), easeIn/Out/InOutElastic (rings around the
target — a playful spring), easeIn/Out/InOutBounce (drops and bounces to rest).
A logo or card “popping” in usually wants easeOutBack; a stamp landing,
easeOutBounce. Physical springs settle to rest within the tween’s duration:
spring (a natural settle), springBouncy (rings more), springStiff (snappy,
barely overshoots) — or tune your own with { spring: { stiffness, damping, velocity } }
(damping ratio = damping / (2·√stiffness); lower ⇒ bouncier).
Scene duration is inferred from the timeline. For a static frame you can omit
timeline entirely (or set scene duration: <seconds>) — a still defaults to a 1s
render; no throwaway wait is needed.
Behaviors: continuous motion during holds
Composed additively on top of the timeline:oscillate(nodeId, prop, { amplitude, frequency, phase? }, window?)— sine.wiggle(nodeId, prop, { amplitude, frequency, seed }, window?)— smooth seeded noise.
{ from?, until?, ramp? } limits the behavior to a
time window (seconds) with a linear fade of ramp (default 0.2s) at each
bound — e.g. a pulse only during the hold:
oscillate("title", "scale", { amplitude: 0.04, frequency: 1.2 }, { from: 1.5, until: 3.5 }).
Omit the window to run for the whole scene.
Camera (one keyframable viewport)
A scene-level camera moves the whole scene at once: a look-at point + zoom + rotation, animated over the timeline. Add it as a top-levelcamera field and
keyframe it with cameraTo (or by tweening the reserved target "camera"):
cameraTo(props, { duration?, ease?, label? })keyframes the camera; it is atweenon the"camera"target, somotionPath("camera", pts, …)(pan along a curve) andoscillate/wiggle("camera", "rotation"|"x"|…)(handheld drift) also work.- Frame a region without clipping — use
cameraFit, not a guessedzoom. The visible scene rect isW/zoom × H/zoomcentred on(camera.x, camera.y), so a hand-pickedzoomthat’s too big crops the target.cameraFit(box, { margin })returns{ x, y, zoom }that frames a scene-space bbox (top-left{x,y,width, height}) with padding, guaranteed in-bounds:cameraTo(cameraFit({ x, y, width, height }, { margin: 80 }), { duration: 1, ease: "easeInOutCubic" }). A centre- anchored panel at(px,py)size(pw,ph)is{ x: px-pw/2, y: py-ph/2, width: pw, height: ph }.maxZoom(default 2.4) caps absurd close-ups. - Pin HUD/titles to the screen with
fixed: trueon a TOP-LEVEL node — the camera won’t move it (for overlays, watermarks, captions). - Defaults are the identity, so a scene without a camera is unchanged. Don’t name
a node
"camera"if you use the scene camera (the id can’t be both).
examples/scenes/camera-demo.ts.
Depth & perspective (projected 2.5D)
Addcamera.perspective (a focal distance in px) to switch on depth. Then any node’s
z (depth) and rotateX/rotateY (3D tilt) take effect: nodes project about the frame
centre by p = perspective / (perspective + z) — further back = smaller and pulled toward
the optical centre. It’s a pure step in evaluate() projected onto the normal 2D matrix, so
renders stay deterministic and the Canvas renderer is unchanged.
- Parallax falls out for free — pan the camera and near (
zsmall) layers shift more than far ones. Dolly = keyframecamera.perspective. Perspective text = give eachsplitTextglyph an increasingzso the word recedes. - A node needs a base value to tween (
rotateY: 0on the card before tweening it to 360). - A tilted group foreshortens its whole subtree (cos folds into children). Clips project
by the group’s depth. A
fixedHUD ignores depth (perspective is part of the camera). - Depth of field (needs
perspective): addcamera.aperture(blur px per unit depth) andcamera.focus(the in-focusz, default 0). A layer at depthdsoftens byaperture·|d − focus|while the focal plane stays sharp; keyframefocusfor a rack focus,aperturefor an iris pull. Absent/0⇒ no blur. HUD/UI text should befixedso it stays crisp (afixednode opts out of DOF too). It feeds the sameblurop, so it composes with an authoredblur. - Occlusion by depth is opt-in: set
camera.zSort: trueand siblings paint far→near (largerzfirst) so nearer nodes cover farther ones without hand-ordering the tree (afixedHUD stays on top; a track-matte group keeps its child order). Off by default — paint stays array order. Gotcha: withzSort, a full-screen background rect atz: 0is the NEAREST plane and paints on top — use the scenebackgroundcolor instead, or give the backdrop a largez. - Limits (honest):
rotateX/rotateYare an affine approximation (cos-foreshorten + keystone skew) — a single rotated quad is really a trapezoid Canvas 2D can’t draw, so it reads as a flip/tilt, not a pixel-true 3D face (that needs WebGL). Depth positioning (parallax, convergence, dolly) IS exact. No GPU 3D, no z-buffer (per-pixel) —zSortorders whole nodes, so two INTERSECTING planes can’t visually cross.
examples/scenes/perspective-cards.ts.
Gradients (fill / stroke)
fill and stroke on rect / ellipse / path accept a gradient as well as a
color string. Coordinates are normalized to the node’s bounding box (0..1), so a
gradient is just an angle + stops:
linearGradient(stops, { angle }),radialGradient(stops, { cx, cy, r }),conicGradient(stops, { angle, cx, cy }).stopsis a color array (even offsets) or[{ offset, color }].cx/cy/rare 0..1 of the box (centre defaults to 0.5).- Gradients are static (not keyframed). The gradient lives in the node’s local
space, so animate the NODE (
tween(id, { rotation: 360 }), scale, move) and the gradient sweeps/stretches with it. Color-string fills still tween as today. - text fill and line stroke are color-only for now. See
examples/scenes/gradient-demo.ts.
Shadow, glow & blur
Drawable nodes (rect / ellipse / path / text / image / line) take animatable paint effects, in screen pixels (not transformed by the node or camera, so a shadow keeps a consistent light direction):- Props:
blur(gaussian blur of the shape),shadowColor(turns the shadow/glow on),shadowBlur,shadowX,shadowY. All animatable —tween/oscillatethem for pulsing glows, focus pulls, etc. (set a base value first so there’s something to animate from). - Sugar:
glow(color, blur)(offset 0) anddropShadow(color, blur, x, y)return a partial you spread into props (...glow("#FFD24B", 28)); still animatable. - On a
groupthese apply to the whole subtree as one composite (focus pull / one silhouette shadow) — see “Group effects” below.examples/scenes/shadow-demo.ts.
Blend modes (compositing)
blend selects how a shape composites with what’s already drawn beneath it — the
primitive that makes light read.
- Modes:
normal(default),multiply,screen,overlay,lighten,darken,add(additive light),color-dodge,soft-light,hard-light,difference. - Discrete, not interpolated — set per node (a static string). Default
normal. - Per-shape, or on a
groupto blend the whole composited subtree against the bg as one layer (see “Group effects” below). Seeexamples/scenes/blend-demo.ts.
Character rig (skeleton, poses, IK)
A first-class, declarative character rig that compiles to plain IR (nestedgroup joints + bone paths) — the character analog of devicePreset. It needs
no new renderer concept, so overlays/preview/determinism all apply.
humanoid({ id, x, y, scale, opacity?, color?, fill?, glow? })→ a NodeIR: a ready upright body. Joints (stable ids${id}-${name}):chest,head,armUpperL/armLowerL,armUpperR/armLowerR,legUpperL/legLowerL,legUpperR/legLowerR. Drop it innodes.rig(boneTree, opts)→ build your own skeleton. ABoneis{ name, at:[x,y], length?, width?, rotation?, shape?, children? }. The joint sits at the group origin; the bone extends +Y at rotation 0; a child’satpivot is in the PARENT bone’s local space (e.g. an elbow at[0, upperLength]). Nested groups give forward kinematics — a child’s rotation composes on its parent’s. Default bone = a bezier capsule (morphable); passshapefor custom art.- A pose is
{ jointName: angleDeg }(0 = bone points down). Animate it:poseTo(id, pose, { duration, ease, stagger? })→ a timeline step (aparof rotation tweens). Sequence poses for wave/jump/run.rigPose(id, pose)→ astatesfragment, to transition withto(state, …).
ikReach(upper, lower, dx, dy, flip?)→[shoulderDeg, elbowDeg]that place a 2-bone limb’s tip at(dx,dy)relative to its shoulder joint (law of cosines; clamps when out of reach). Feed the two angles into a pose.- Joint names are the stable regen addresses — never rename them across a
regen; each rig instance needs a distinct
id(duplicates collide via scene validation). Squash/stretch and expressions are per-bonedmorphs (above), composed on top of FK posing. Idle sway/breathing =oscillateon a joint. figure(opts)— a dressed character (the styled sibling ofhumanoid): same skeleton, but coloured flat-design shapes.style: "clean"(corporate-flat / undraw register, the default) or"cute"(mascot);paletteknobs (skin/hair/top/pants/shoe/accent) re-skin it — forcleanthe top followsaccent, sofigure({ palette: { accent: "#3B82F6" } })recolours the whole figure;face: falsemakes it faceless. It exposes the humanoid joint ids, socharacterPreset/ikReachdrive it unchanged. Use it as the supporting actor in a product promo (gesturing at adevicePreset), not the hero.characterPreset(name, opts)— a seeded motion generator for ahumanoidorfigurerig (the character analog ofmotionPreset). Returns a composablebeat; drop it in the timeline:seq(characterPreset("walk", { target: "hero", at: [cx, cy], cycles: 4 })). Names:walk,run,jump,dance,wave,cheer. Knobs:target(rig id),energy0..1,speed(>0, divides durations),seed(varies within the family),cycles(walk/run/dance),facing(±1),at: [x,y](the rig’s scene position — needed for walk travel & jump lift),travel(px/cycle, 0 = in place),label(unique beat name — set it when the same preset is used more than once in a scene). Legs useikReach, arms FK; pure keyframes, so add continuous idle yourself withoscillate.
Kinetic text (split + effect presets)
reframe’stext node renders a whole string as one node, so per-glyph effects
need the string split into per-character nodes. splitText does that once;
seeded effect generators animate the glyphs (the text analog of motionPreset).
splitText(text, { id, x, y, fontSize, fontWeight?, fill?, letterSpacing?, align?, unit?, opacity? }) → TextBlock— lays the phrase out as center-anchoredtextnodes using real Inter advance widths (so layout matches the render). Returns{ nodes, glyphs, ids, width, ... }; put...block.nodesinnodes. Glyph ids are${id}-${i}(stable regen addresses).unit: "word"animates whole words instead of letters;opacity: 0(default) starts hidden for entrances.textIn(name, block, { speed?, energy?, seed?, stagger?, label? }) → TimelineIR(abeat) — entrance:typewriter,cascade,rise,bounce,assemble(fly in from a seeded scatter),decode(scramble through random glyphs then lock).textLoop(name, block, { from?, until?, ramp?, amplitude?, frequency?, phaseStep? }) → BehaviorIR[]— sustained:wave(standing sine),shimmer,wobble,float. Spread it intobehaviors.textOut(name, block, { …, dir? }) → TimelineIR— exit:shatter(random direction + spin + fade),fly(directional),dissolve,fall,collapse.textTypeCues(block, { at, interval?, gain? }) → AudioCueIR[]— per-glyph CC0 keypress for a typewriter entrance; spread intoaudio.cues.
seed → identical) and pure keyframes. To time a
textLoop window, add up the textIn beat length (≈ (n-1)·stagger + glyphDur).
Photo / video montage (photoMontage / videoMontage)
Turn a list of shots into a polished slideshow — crossfades + seeded Ken Burns
(pan/zoom) + an optional cinematic grade (vignette + bottom scrim via gradients +
blend) — without hand-wiring each move. Shots may be images AND video clips, mixed
freely (a video src, by extension, plays as a clip for its hold). videoMontage
is the same generator, named for clip-driven cuts. The photo analog of motionPreset.
- Returns
{ nodes, timeline }(likesplitTextowns its glyph nodes).nodesare the stacked image/video layers (+${id}-vignette/${id}-scrimgrade overlays);timelineis a retimablebeat("montage", …). Stable addresses:${id}-${i}, labelsshot-${i}/cross-${i}. - Any-aspect media works — each layer uses
fit: "cover", so the renderer crops to fill the frame at the source’s aspect (no pre-cropping, no distortion). The Ken Burns keepsscale ≥ 1with the pan bounded to its slack, so an edge is never revealed. - Per-shot overrides:
{ src, hold?, ken?, volume? }wherekenis"in" | "out" | "pan". A video shot plays as a clip from its slot’s start; its audio is muted by default in a montage — setvolume(per shot) to include it, or add ascene.audiobed. - Seeded + pure (same
(shots, opts)→ identical IR). Note: image/video sources do not render inreframe player/ artifacts — montage ships as mp4. Seeexamples/scenes/video-montage.ts. - Assemble from files:
reframe assemble <media...> [-o name] [--title "…"] [--bgm <synth>] [--hold s] [--seed N]probes each clip’s real duration (so a video shot’shold= its actual length, never a freeze) and scaffolds an editable scene.tswiringphotoMontage+ an optionaltitle+ a bed. The probed numbers are baked in, so the emitted scene is a normal deterministic scene — edit it (reorder, retime, swap asrc), thenreframe renderit.
Titles & lower-thirds (title / lowerThird)
The motion-graphic overlay vocabulary for a media piece — generators that return
{ nodes, timeline } to compose over a montage (or anything). Stable ids so overlays
address them; pure + deterministic.
title({ text, id?, x?, y?, fontSize?, fontWeight?, fill?, letterSpacing?, entrance?, exit?, speed?, seed?, hold? })→{ nodes, timeline, block }. A kinetic headline built onsplitText+textIn(entrance presets:cascaderisebouncetypewriterassembledecode). Setexit(atextOutpreset) and it plays in, holdsholds, then exits. Glyph ids${id}-${i}; labels${id}-in/${id}-out.blockis returned so you can addtextLoopbehaviors or extra tweens.lowerThird({ name, role?, id?, x?, y?, accent?, fill?, subFill?, fontSize?, hold? })→{ nodes, timeline }. A name/role strap: an accent bar grows in, the text slides + fades. Ids${id}(group) /${id}-bar/${id}-name/${id}-role; labels${id}-in/${id}-out. Defaults to a bottom-left title-safe position.
examples/scenes/media-story.ts.
Video clips (video)
Draw a video clip as a layer. It plays on the scene clock — at scene-time t it
shows the source frame at clipStart + max(0, t - start) * rate.
- Props:
src(mp4 / mov / webm / m4v / mkv, absolute or scene-relative),width/height,fit("cover"like the image node),start(scene-time playback begins),rate(speed),clipStart(source in-point s),volume(clip-audio gain, default 1;0mutes). Transform/opacity/effects compose as usual. startcan be a label (not just a number):start: "shot-2"anchors playback to that timeline label’s time (likebeat.at), so the clip ripples when its shot is retimed (by an overlay or AI regen) instead of desyncing.photoMontagedoes this automatically for video shots.- Deterministic by frame extraction: render-cli runs
ffmpeg -vf fps=<sceneFps>to pull the clip’s frames, and the renderer draws frameround(t·fps)— no live<video>seek, so it stays byte-identical (same machine). - Clip audio: the clip’s own audio track is muxed into the output, placed at
start(trimmed fromclipStart, sped byrate, scaled byvolume), mixed withscene.audio. A clip with no audio stream is skipped; setvolume: 0to drop a clip’s sound. - Limitations: all frames are pre-decoded so keep clips short (≤~5s); like images, video
sources are not rendered in
reframe player/ artifacts (mp4 only). Seeexamples/scenes/video-demo.ts.
Track mattes (group({ matte }))
Use one layer’s alpha or luminance to mask another — video-filled text, shape /
PNG punch-through, luma wipes. In a matte group the FIRST child is the matte;
the remaining children are the masked content.
matte: "alpha"keeps content where the matte is opaque;"luma"where it’s bright (animate a gradient/shape as the matte for a wipe). Needs ≥2 children.- The group’s transform / opacity / clip apply as usual (a centered group scales about its middle; fading the group fades the masked result). Mattes can nest.
- Rendered by offscreen compositing (the matte + content draw to separate buffers,
combined via
destination-in). Deterministic same-machine. Seeexamples/scenes/matte-demo.ts.
Group effects (blur / shadow / blend on a whole group)
The paint effects (blur, shadowColor/shadowBlur/shadowX/shadowY, blend) also
work on a group — there they apply to the whole subtree as ONE composite layer, not per
child. The classic uses: a depth-of-field focus pull on a multi-node lockup, a single
silhouette drop shadow under a multi-shape mark, and a group that blends against the
background as one layer (so its own overlaps composite together).
- Group
bluris animatable (tween(group, { blur })); shadow scalars too. - Same offscreen compositing as mattes (the subtree renders to a buffer, drawn back once
with the effect). It wraps a matte group and nests. The effects are screen-pixel space.
See
examples/scenes/group-fx-demo.ts.
Device frames (phone / browser / laptop …)
To put a phone, browser, laptop, … on screen, use the preset — don’t hand-draw a device out of rects.devicePreset(name, opts) → NodeIR returns a parametric
vector frame (bezel, rounded body, phone notch / dynamic island, browser chrome).
devicePreset(name, { id, x, y, scale?, opacity?, orientation?, content })— names:phonetabletlaptopbrowserwatchmonitortvfoldableterminalcar. There is no"iphone"—"phone"IS the iOS-style frame (notch + dynamic island).browser/terminaltake anaddressstring.contentnodes are authored in screen-LOCAL centre coords (0,0 = screen centre) and clipped to the screen. Stable ids${id}-screen/${id}-content(overlay/regen addresses) — keepidacross rewrites.- It’s one node: animate the device group for the float/entrance (
tween/motionPathitsx/y/scale/rotation,oscillatefor an idle drift). - Premium by default —
material:"premium"(the default) gives a gradient body, an ambient screen glow, a soft contact shadow and (glass) a sheen; thestyleknob picks"glass"(realistic glass/metal, default) or"neon"(flat body + additive accent edge-glow, graphic punch).material:"flat"opts back to clean solid fills. All of this is purely cosmetic — the screen rect, the clip, and the stable ids are identical across materials/styles, sodeviceScreencoords and existingcontent/overlays are unaffected. - Auto-varied per instance — each device’s look (bezel, corner, glare angle,
neon hue) is derived deterministically from its
id, so two devices differ while staying on-model. Passseedto pin or explore a variation; sameseed→ identical, differentseed→ same family. Reproducible (noMath.random). notch?: "island" | "notch" | "punch" | "none"selects the phone front-camera treatment (default"island"— keep it explicit for an iOS vs Android read).- See
examples/scenes/device-gallery.tsfor glass/neon + seed variation.
cursor + deviceScreenPoint (below) to click UI inside the device.
Cursor (UI demos)
A vector mouse pointer that glides across the scene and clicks things — for app walkthroughs.cursor() returns a node; the moves/clicks return timeline steps.
The pointer’s hotspot is the group origin, so a move lands the tip on a target.
cursor({ id, x, y, scale?, opacity?, style?, accent? }) → NodeIR— stylesarrow(default),dot,ring. Draw it LAST so it sits on top. Carries a hidden${id}-ripplering for clicks.cursorTo(id, from, to, { duration?, ease?, arc? }) → TimelineIR— glide along a gentle human arc (arcis the bow, default 0.12). Thread the position: start = the node’sx/y, eachtobecomes the nextfrom.cursorPath(id, points, opts)— a multi-stop tour through waypoints.cursorClick(id, { press?, ripple?, label? })/cursorDouble(...)— the pointer taps, a ripple ring expands, and thepressnode (a button) dips. Pass a uniquelabelwhen you click more than once in a scene.deviceScreenPoint(name, deviceOpts, [lx, ly]) → [x, y]— map a UI element’s screen-local coords (the coordsdevicePresetcontentis authored in) to scene coords, so the cursor clicks on-screen UI precisely (account for the device’sscaleat click time and anyslotoffset).
Audio (optional)
Label-anchored sound design — cues follow retiming and regeneration:sfx/file per cue):
| group | names |
|---|---|
| transition | whoosh swish swoosh rise riser warp |
| ui | tick click blip pop select |
| impact | thud boom knock sub |
| positive | chime ding coin sparkle shimmer success |
| alert | zap error |
| tech | glitch static scan powerup powerdown |
| rhythm | snare hat |
| foley | bubble notify camera |
seed shifts the sound’s
PITCH (a musical step) and texture, and it defaults to the cue’s order, so a run of
the same sfx becomes a little phrase instead of a stuck note — no setup needed. Override
explicitly with params: { sfx: "blip", params: { seed: 4 } } (pick the variant) or
{ sfx: "tick", params: { pitch: 1.5 } } (an explicit frequency multiplier; 2 = octave
up). params.gainDb trims a single hit.
Auto-foley — the motion scores itself. audio: { autoFoley: true } derives sound
cues from node motion, no manual cues needed: a fast move → whoosh/swish at the
velocity peak, a moving node that settles → thud/knock, a scale-in → pop, each
panned by its on-screen x. It’s a pure pass over the compiled motion, so it’s
deterministic AND retime/regeneration-safe — retime a step and the sound follows.
Manual cues still layer on top (and win). Best for discrete-element scenes; on dense
scenes use the options to stay tasteful: autoFoley: { gain?, whoosh?, impact?, pop?, pan?, sensitivity?, maxCues?, nodes?: [ids] } (maxCues keeps the loudest, nodes
allowlists). See examples/scenes/auto-foley-demo.ts (zero manual cues).
bgm beds: synthesized via bgm.synth (ambient-pad lofi pulse tension
uplift), or a file via bgm.file — bundled CC0 music: bgm-song21.mp3 (ambient),
bgm-synthwave.mp3 (chill), bgm-piano.mp3 (elegant), bgm-battle.mp3 (energetic),
or your own path. Mixing: any cue takes fadeIn/fadeOut (seconds) and pan
(-1 left … 0 centre … +1 right). A video clip’s audio takes fadeIn and pan too
(clip fade-out isn’t supported yet). The bed auto-ducks under cues (bgm.duck).
Recorded samples are a separate layer from the synth palette: use a file: cue to
play a CC0 file from assets/sfx/ — keyboard typing (keypress-*.wav, also driven by
textTypeCues), footstep_*, and the Kenney UI pack (click_*/confirmation_*/
select_*/…). Six “hero” names (whoosh/rise/shimmer/thud/pop/tick) default to a
curated CC0 sample (better fidelity, fixed — no pitch-vary); add params: { synth: 1 }
to use the varying synth instead. Every other sfx: name synthesizes. Audition the procedural set
with examples/scenes/sfx-showcase.ts and the samples with sample-showcase.ts.
Rules
- Everything must be a pure function of time: no
Math.random()(usewigglewith a seed), noDate, no async. - Node ids must be unique; states/tweens may only reference existing ids and real props of that node type.
- Overshoot pops are two steps: tween scale past 1 (
1.15), then settle to 1. - When a node enters by scaling from 0, start it at
opacity: 0too and fade in alongside — a scale-0 shape can still rasterize as a 1px dot at frame 0.