Composing — the complete reference
This page is the single source of truth for writing .comp.mjs composition files
with @onda-engine/react. Read it before authoring; it covers the full node surface,
effects, animation, timeline, camera, component shortcuts, and the footguns that
cause silent bugs or crashes.
The full node surface
Section titled “The full node surface”Every visual primitive (Group, Rect, Ellipse, Path, Text, Image,
Video, Svg) accepts NodeProps:
| Prop | Type | Notes |
|---|---|---|
x, y | number | Translate in px. |
scaleX, scaleY | number | Scale factor (1 = identity). |
rotation | number | Clockwise degrees. Vello/GPU backend only — CPU ignores it. |
originX, originY | number | Pivot for scale + rotation, in px (default 0,0 = top-left). |
opacity | number | 0..1. |
clip | ClipInput | Clip the node + its subtree. clipRect(w,h,r?), clipEllipse(w,h), clipPath(d). GPU only. |
matte | ReactElement | Reveal this node through another subtree’s alpha/luminance — masks, shape wipes, gradient reveals. |
matteMode | 'alpha' | 'luminance' | Default 'alpha'. |
blendMode | string | CSS blend mode (GPU backend). |
blur | number | Gaussian blur σ in px — render-to-texture. Both backends. |
directionalBlur | object | { sigma, angle } — 1D motion-blur smear along angle (radians); reads as in-motion. Both backends. |
backdropBlur | number | Frosted-glass blur of what is behind this node. GPU only. |
bloom | object | { threshold?, intensity?, radius? } — bright regions bloom outward. GPU only. |
grade | object | { brightness?, contrast?, saturation?, temperature?, tint? } — color grade (CSS-style, 1 = identity). |
grain | number | object | Film grain. 0.04–0.1 is filmic. Object form: { intensity, size?, seed? }. |
goo | object | { sigma, threshold? } — metaball/goo blend between sibling shapes. GPU only. |
lightWrap | object | { sigma, strength?, backdropNode? } — bleeds backdrop light onto the node’s feathered edges. GPU/export only. |
shadow | object | { color, blur, offsetX?, offsetY?, spread? } — drop shadow behind the node. |
chromaticAberration | number | Lens RGB split — amount in px, red/blue channels shifted radially from centre. Both backends. |
vignette | number | object | { amount, softness? } — radial edge darkening; number is shorthand for amount. Both backends. |
posterize | number | Quantize each channel to N levels (cel / screen-print look). Both backends. |
duotone | object | { shadow, highlight } — map luminance onto two colors (Spotify-poster look). Both backends. |
chromaKey | object | { color, threshold?, smoothness? } — knock out a key color (green screen) to transparent. Both backends. |
effects | Effect[] | Raw effects array — prefer the sugar props above. |
Cinematic finish (<Composition finish={…}>)
Section titled “Cinematic finish (<Composition finish={…}>)”A composition-level finishing chain run after the comp rasterizes, in scene-linear
light with HDR headroom, ending in one ACES film tone-map — the “looks shot” output
transform. Unlike per-node effects (which Vello hands back 8-bit between passes, so HDR
is lost), the finish keeps everything in float to a single tone-map, so bloom highlights
bleed real light and roll off filmically. GPU/export only (the CPU reference and a
WebGPU-less browser render un-finished — judge it on the native/export render). Every
field defaults to a no-op, so finish={{ bloom: { sigma: 16 } }} is just bloom + ACES.
<Composition width={1920} height={1080} fps={30} durationInFrames={120} finish={{ bloom: { sigma: 18, threshold: 0.2, intensity: 2.4 }, // linear-HDR glow halation: 0.7, // warm red/orange fringe around highlights temperature: 0.22, // grade: + warm / − cool contrast: 1.12, // grade: contrast around mid-grey (1 = identity) saturation: 1.08, // grade: 1 = identity, 0 = greyscale vignette: 0.42, // radial edge darkening grain: 0.05, // luminance-banded film grain (frame = seed, automatic) exposure: 1.0, // linear exposure before the tone-map }}> {/* … */}</Composition>Chain order: bloom + halation → exposure → grade → vignette → grain → ACES.
Motion blur (<Composition motionBlur={…}>)
Section titled “Motion blur (<Composition motionBlur={…}>)”Shutter-angle per-object motion blur via temporal supersampling: each output frame
is the average of samples sub-frames spread across the shutter window, so anything
that moves smears by its own motion (translation, rotation, scale — anything that’s
a function of the frame) while static elements stay sharp. Export-only — cost is
samples× the render, so the live preview shows the sharp frame.
// `true` = a 180° shutter at 16 samples; or tune both:<Composition motionBlur={{ shutter: 180, samples: 16 }} …>shutter is in degrees (180 = half the frame, the film default; 360 = full-frame,
heavier). More samples = a smoother smear at linear cost.
Depth of field (<Composition dof={…}> + depth)
Section titled “Depth of field (<Composition dof={…}> + depth)”A 2.5D rack focus: give each layer a depth (any units) and the comp a
dof={{ focus }}, and every layer defocuses by how far its depth is from the focus
plane — sharp at focus, blurrier away from it. Animate focus for a focus pull
between layers. It resolves to a per-layer blur (reuses the blur pass), so it
works on both backends and the live preview — no true 3D, the depth is yours to assign.
<Composition dof={{ focus: 400, aperture: 0.06, range: 28 }}> <Group depth={900}><Background /></Group> {/* far → blurred */} <Group depth={400}><Subject /></Group> {/* at focus → sharp */} <Group depth={120}><Foreground /></Group> {/* near → blurred */}</Composition>aperture = blur px per unit of depth past the in-focus band (bigger = shallower DoF);
range = a sharp band ± focus; maxBlur clamps the σ (default 40).
3D scene (<Scene3D camera={…}> + position3d / rotation3d)
Section titled “3D scene (<Scene3D camera={…}> + position3d / rotation3d)”<Scene3D> places its direct children in one shared perspective 3D world. Each
child is a flat plane (its rendered 2D content) positioned by position3d={[x, y, z]}
and tilted by rotation3d={[x, y, z]} (degrees: X pitch, Y yaw, Z roll), viewed through
a perspective camera. Layers depth-sort and occlude (a real depth buffer — they can
even intersect). This is the mograph core of “3D”: camera fly-throughs, card walls /
cover-flow, parallax, exploded UI, billboard/3D titles.
z goes into the screen (the After Effects convention): larger z is farther
(smaller), negative z nearer the camera. z = 0 is the framing plane — a layer there
with no rotation renders pixel-identical to its 2D placement, so wrapping content in
<Scene3D> changes nothing until you push layers in z. Default position3d is the
layer’s center on its world point. Omit camera fields to get a default camera that
frames z = 0 to fill the comp.
import { Scene3D } from 'onda-engine/react'
<Scene3D camera={{ position: [960, 540, -1400], target: [960, 540, 0], fov: 45 }}> <Card position3d={[640, 540, 250]} rotation3d={[0, 55, 0]} /> {/* yawed, behind */} <Card position3d={[960, 540, 0]} /> {/* flat, at framing plane */} <Card position3d={[1280, 540, 250]} rotation3d={[0, -55, 0]} /> <Title position3d={[960, 180, -120]} /> {/* nearer → larger */}</Scene3D>Extruded 3D solids (extrude) — inside a <Scene3D>, a shape or text layer
becomes a lit 3D solid instead of a flat plane: extrude={depth} grows a front +
back face (tessellated with holes handled) joined by side walls, shaded by a directional
light so it catches the light as it rotates. The spinning solid logo / kinetic 3D
title:
<Scene3D camera={{ position: [640, 360, -1500], fov: 42 }}> <Text fontSize={220} color="#e8edff" position3d={[640, 360, 0]} rotation3d={[16, 28, 0]} extrude={80}>ONDA</Text> <Path d="M120 40 H200 …Z" fill="#f5b942" position3d={[300, 360, 0]} rotation3d={[16, -34, 0]} extrude={80} /></Scene3D>Judge 3D on a native/export render, never the live preview. The GPU (Vello) path runs the true perspective + out-of-plane rotation (the trapezoidal foreshortening of a yawed card) and the lit extruded solids. The CPU reference and the web preview degrade to a 2.5D depth-sorted projection — perspective scale + position only, no tilt — and draw extruded layers as their flat outline. (Importing arbitrary 3D models / glTF meshes is a later phase; shapes, paths, and text extrude today.)
Shape operators (mograph)
Section titled “Shape operators (mograph)”Operations on the path/geometry itself, the AE-shape-layer vocabulary:
-
Trim paths —
trimStart/trimEnd/trimOffset(fractions 0..1) on any stroked shape draw only that arc-length slice of the outline. AnimatetrimEnd0→1 for a line-draw (logos, underlines, signatures, loaders). The engine measures the path length, so you never touch pixel lengths; identical on GPU + CPU.<Ellipse stroke="#e85494" strokeWidth={14} strokeCap="round"trimEnd={interpolate(frame, [0, 40], [0, 1], { extrapolateRight: 'clamp' })} /> -
<Repeater>— stamp a subtreecounttimes, each copy COMPOUNDING one more step of the transform (offset / rotation / scale) + a step of the opacity ramp: grids, radial arrays, spirals, motion trails. Nest two for a 2D grid. Pure composition — renders the same on every backend, live and exported.<Repeater count={12} rotation={30} originX={180} originY={180}><Rect width={24} height={70} cornerRadius={12} fill="#e85494" /></Repeater>Props:
count,offsetX,offsetY,rotation(deg/copy),scale(factor/copy),originX/originY(pivot),startOpacity/endOpacity(ramp). -
<Merge op="…">— boolean “merge paths”: combine the SHAPE children into ONE outline.opisunion(add),difference(the first minus the rest),intersect(common area), orxor(symmetric difference). A ring = circle − circle; a lens = circle ∩ circle; a speech bubble = rect ∪ triangle. The children fold into the result (not drawn separately) — fill/stroke the<Merge>itself. Both backends.<Merge op="difference" fill="#5ad1ff"><Ellipse width={130} height={130} /><Ellipse x={70} width={120} height={120} /></Merge>
Particles (<Particles>)
Section titled “Particles (<Particles>)”A deterministic particle emitter — bursts, fountains, confetti, sparks, dust,
snow. Every particle’s whole state (spawn, velocity, age, size, opacity, colour,
spin) is a PURE function of frame + seed + index, so it scrubs and exports
deterministically (no Math.random). Frame-based units (velocity = px/frame,
gravity = px/frame²); each live particle is a plain shape — position/size/opacity/colour
are identical on CPU + GPU, while spin (rotation) is GPU-only.
<Particles count={170} seed={11} x={W / 2} y={190} speed={5} speedVariance={0.55} // launch speed (px/frame) + random reduction angle={-90} spread={360} // direction (deg; −90 = up) + cone (360 = omni) gravity={0.08} // downward accel (px/frame²) lifetime={70} emitOver={0} loop={false} // life (frames); stagger; re-emit for a fountain shape="square" size={[13, 6]} opacity={[1, 0.55]} spin={420} colors={['#e85494', '#ffd36b', '#5ad1ff', '#27b78d']} spawnRadius={0} // spawn within this radius of the origin/>Precomp & adjustment layers
Section titled “Precomp & adjustment layers”A <Group>’s opacity/blendMode apply per child, so fading a group of
overlapping layers double-darkens the overlaps (the AE “needs a precomp” gotcha).
<Precomp> flattens the subtree to ONE layer first (render-to-texture), so
opacity/blend/effects apply to the composited result — both backends.
<Precomp opacity={0.5}> {/* the overlaps stay uniform, not doubled */} <Card /> <Card /></Precomp>An adjustment layer (an effect over everything beneath it) is just a <Group>
with the effect wrapping the layers it should affect:
<Group grade={{ saturation: 0 }}> {/* desaturates everything inside */} <Background /> <Subject /></Group>Audio-driven motion (sync to music)
Section titled “Audio-driven motion (sync to music)”useAudioBeats(src) (from @onda-engine/components) analyzes an audio clip into
{ tempo, beats, onsets, onsetEnv } — all in frame units — using the same
@onda-engine/wasm-audio as the FFT, so it’s deterministic and identical in preview and
export. beats / onsets are frame indices; onsetEnv[frame] is a 0..1 strength.
Drive any property with the pure helper beatPulse(frame, beats, decay) — a 1 → 0
punch that fires on each beat — so an element hits on the beat:
import { useAudioBeats, beatPulse, isBeat } from 'onda-engine/components'
function Kick() { const frame = useCurrentFrame() const b = useAudioBeats('/music.mp3') const beats = b?.beats ?? [] const punch = beatPulse(frame, beats, 8) // 1 on the beat → 0 over 8 frames return <Ellipse width={300 * (1 + 0.4 * punch)} … /> // scales up on every beat}isBeat(frame, beats) gates hard cuts/swaps; onsetEnv[frame] drives a glow off any
transient (not just the grid). This is the “edited to the music” layer — cut on the
beat, punch on the kick, drop text on a transient.
For a fully deterministic export an agent can bake the
beatsarray into the composition as a constant (analyze once) and use the pure helpers directly — no async load at render time.
Colors
Section titled “Colors”ColorInput is a hex string (#rgb, #rgba, #rrggbb, #rrggbbaa) or { r, g, b, a? } (0..1 channels). 'none' and 'transparent' are accepted and treated as fully transparent. Never pass a bare string that isn’t a hex color — it will throw.
import { clipRect, clipEllipse, clipPath } from 'onda-engine/react'<Group clip={clipRect(300, 100, 12)}> … </Group>Clip regions are in the node’s local space. GPU only — the CPU backend ignores them.
Animation
Section titled “Animation”import { spring, interpolate, Easing, useCurrentFrame, useVideoConfig } from 'onda-engine/react'useCurrentFrame()
Section titled “useCurrentFrame()”Returns the current frame number (0-based). The only way to read time inside a component.
spring()
Section titled “spring()”Physics spring from from → to. Returns a single number.
spring({ frame, // current frame, shifted to 0 at the spring's start fps, // frames per second from?: number, // start value (default 0) to?: number, // end value (default 1) config?: { damping?: number, // higher = less bounce (200 = overdamped, no bounce) stiffness?: number, // higher = faster spring (100 = moderate) mass?: number, // higher = slower (default 1) }, durationInFrames?: number, // cap duration for a deterministic settle})Useful presets:
const SPRING_SMOOTH = { damping: 200, stiffness: 100, mass: 1 } // overdamped, slides inconst SPRING_SNAPPY = { damping: 120, stiffness: 180, mass: 1 } // decisive, no bounceconst SPRING_POP = { damping: 14, stiffness: 100, mass: 1 } // underdamped, bouncesinterpolate()
Section titled “interpolate()”Map an input range to an output range, with optional easing.
interpolate(frame, [0, 30], [0, 1], { easing: Easing.easeOutCubic, extrapolateLeft: 'clamp', extrapolateRight: 'clamp',})Timeline
Section titled “Timeline”import { Sequence, Loop, Series, TransitionSeries } from 'onda-engine/react'<Sequence from={N} durationInFrames={D}>
Section titled “<Sequence from={N} durationInFrames={D}>”Shifts children in time — inside, useCurrentFrame() returns outerFrame - N.
Children only render while 0 ≤ localFrame < durationInFrames.
// Title appears at frame 30, lasts 60 frames.<Sequence from={30} durationInFrames={60}> <Title /> {/* sees frame 0..59 */}</Sequence><Loop durationInFrames={D}>
Section titled “<Loop durationInFrames={D}>”Children see frame % D — repeats forever for as long as the composition runs.
<Series>
Section titled “<Series>”Sequences auto-stacked — no need to calculate cumulative offsets. Each
<Series.Sequence durationInFrames={D}> starts exactly when the previous one ends.
<Series> <Series.Sequence durationInFrames={60}><Intro /></Series.Sequence> <Series.Sequence durationInFrames={90}><Main /></Series.Sequence> <Series.Sequence durationInFrames={30}><Outro /></Series.Sequence></Series><TransitionSeries>
Section titled “<TransitionSeries>”Like <Series> but with animated transitions between segments.
import { TransitionSeries, crossFade, springTiming } from 'onda-engine/react'
<TransitionSeries> <TransitionSeries.Sequence durationInFrames={60}> <SceneA /> </TransitionSeries.Sequence> <TransitionSeries.Transition timing={springTiming({ damping: 200 })}> {crossFade()} </TransitionSeries.Transition> <TransitionSeries.Sequence durationInFrames={90}> <SceneB /> </TransitionSeries.Sequence></TransitionSeries>Available transitions: crossFade, fade, slide, push, flip, iris,
blur, glassWipe, clockWipe, gridPixelate, depthPush, devicePullback,
expandMorph, dipToColor, morph, chromaticAberration, none.
Camera
Section titled “Camera”<Camera> is a world-space pan/zoom viewport. Children inside it are rendered in
world coordinates; the Camera translates and scales so that focusX/focusY
lands at the canvas center, scaled by zoom.
import { Camera } from 'onda-engine/react'
<Camera focusX={960} focusY={540} zoom={1.5} rotate={0}> {/* world-space content */}</Camera>| Prop | Type | Notes |
|---|---|---|
focusX, focusY | number | World point that maps to the canvas center. |
zoom | number | 1 = identity; >1 zooms in; animate for a dolly. |
rotate | number | Degrees (GPU/Vello only). |
Animate zoom or focusX/focusY with spring() for smooth camera moves.
@onda-engine/components — ready-made animation primitives
Section titled “@onda-engine/components — ready-made animation primitives”Import from @onda-engine/components. These wrap Group + spring/interpolate in
one line so you don’t hand-code enter/exit math.
Entry animations
Section titled “Entry animations”| Component | What it does |
|---|---|
<FadeIn delay? durationInFrames?> | Opacity 0 → 1 on the house spring. |
<ScaleIn from? delay? durationInFrames?> | Scale from → 1 (default 0.9), centered. |
<SlideIn direction? distance? delay?> | Translates in from off-screen. |
<RotateIn degrees? delay?> | Rotates from degrees → 0. |
<TrackingIn delay?> | Letter-spacing collapses in (kinetic text reveal). |
Exit animations
Section titled “Exit animations”<FadeOut>, <SlideOut>, <ExitScale> — mirrors of the entry set.
Stagger
Section titled “Stagger”<StaggerGroup staggerFrames? children> — applies increasing delays to each child.
| Component | What it does |
|---|---|
<Typewriter text speed? startFrame?> | Characters appear one by one. |
<KineticText preset? text> | Word/glyph reveal presets ('fade-up', 'slide-left', 'scale-in'). |
<WordStagger text staggerFrames?> | Staggers each word of a string. |
Motion vocabulary (pure functions, no JSX needed)
Section titled “Motion vocabulary (pure functions, no JSX needed)”import { entryScale, entryFade, entryFadeRise, entrySlide, exitFade, stateSwap } from 'onda-engine/components'
const { opacity, scaleX, scaleY } = entryScale({ frame, fps, delay, durationInFrames, from })Returns plain numbers; apply them as props directly.
Other useful components
Section titled “Other useful components”<DrawOn> (path stroke-on), <MaskReveal>, <GradientShift>, <KenBurns>,
<CountUp>, <ProgressBar>, <Terminal>, <InputField>, <IconPop shape="check|star|dot|cross">.
Iteration workflow (fast feedback loop)
Section titled “Iteration workflow (fast feedback loop)”The render script has three modes. Use the fastest one for the task:
# Check one frame — fastest (~5s). Great for layout/color/spring-settle checks.node apps/site/scripts/render-comp.mjs --comp my.comp.mjs \ --fps 30 --duration 1470 --backend vello --no-build \ --frame 300# → /tmp/onda-frame-300.png
# Check a scene window — fast (~15-60s). Render just the frames you're editing.# Produces a standalone short clip from those frames.node apps/site/scripts/render-comp.mjs --comp my.comp.mjs \ --fps 30 --duration 1470 --backend vello --no-build \ --frames 240:360 --out /tmp/scene-log.mp4
# Full render — use only for final delivery or cross-scene checks.node apps/site/scripts/render-comp.mjs --comp my.comp.mjs \ --fps 30 --duration 1470 --backend vello --no-build \ --out /tmp/full.mp4Agent iteration loop: edit comp → --frame N (5s) → if layout is right, --frames start:end (scene clip, ~30s) → if scene is right, full render only for final check.
Composition file template
Section titled “Composition file template”import { createElement as h } from 'react'import { Composition, Group, Rect, Ellipse, Path, Text, Image, Camera, Sequence, spring, interpolate, useCurrentFrame,} from 'onda-engine/react'
const FPS = 30const W = 1280, H = 720const T = (s) => Math.round(s * FPS)
export default function myComp({ fps, durationInFrames, width, height }) { return h(Composition, { width, height, fps, durationInFrames }, h(Scene, null), )}
function Scene() { const frame = useCurrentFrame() const pop = spring({ frame, fps: FPS, durationInFrames: 20 }) return h(Group, { opacity: pop }, h(Rect, { x: 0, y: 0, width: W, height: H, fill: '#111' }), h(Text, { x: 80, y: 200, fontSize: 80, color: '#fff' }, 'Hello'), )}Known footguns
Section titled “Known footguns”These cause silent misrenders or hard crashes. Memorize them.
fill: 'none' → use '#00000000'
Section titled “fill: 'none' → use '#00000000'” used to crash with a parse error. It now maps to transparent, but prefer fill="none"'#00000000' for clarity.
&& children in Group → use a ternary
Section titled “&& children in Group → use a ternary”// WRONG — if cond is false, Group receives false as a childh(Group, null, cond && h(Rect, ...))
// RIGHT — ternary with a zero-size placeholderh(Group, null, cond ? h(Rect, ...) : h(Rect, { width: 0, height: 0, fill: '#00000000' }))Pre-computed element variables → always inline
Section titled “Pre-computed element variables → always inline”// WRONG — closures can capture stale reconciler stateconst card = h(NodeCard, props)h(Group, null, card)
// RIGHT — inline the callh(Group, null, h(NodeCard, props))Components must not return null → use opacity: 0
Section titled “Components must not return null → use opacity: 0”// WRONG — ONDA's reconciler treats null as an unexpected empty nodeif (frame < startF) return null
// RIGHT — render with opacity 0 (or a zero-size placeholder Rect)return h(Group, { opacity: frame < startF ? 0 : 1 }, ...)rotation is GPU only
Section titled “rotation is GPU only”rotation renders correctly on Vello/export. The CPU backend (--backend cpu) ignores it. Do not rely on rotation for final verification using the CPU reference renderer — always check on Vello.
blur, backdropBlur, grain, bloom, lightWrap are GPU/export only
Section titled “blur, backdropBlur, grain, bloom, lightWrap are GPU/export only”These render-to-texture effects produce nothing on the CPU backend. Verify them with --backend vello, never cpu.
clip is GPU only
Section titled “clip is GPU only”clip (and matte) are Vello-only — the CPU backend skips them silently.