Skip to content

Core Concepts

Composition

Every script calls composition(config, build) once. The config sets the canvas size, frame rate, and background. The build function receives a Builder and adds content to it.

ts
composition({ width: 1920, height: 1080, fps: 30, duration: "5s" }, (comp) => {
  // add layers here
});

There are two ways to set the length:

  • Duration mode: set duration in the config and add layers directly with comp.add(...).
  • Scene mode: call comp.scene(...) one or more times. The total length is the sum of the scenes minus the transition overlaps, and duration in the config is then optional.

Coordinates

Positions are in pixels, measured from the top-left of the canvas. A layer sits at its position by its anchor, which defaults to its center, so a text layer at the canvas center is centered on that point. comp.center gives [width / 2, height / 2].

Set anchor(x, y) to change the pivot, as a fraction of the layer's bounds. For example anchor(0.5, 1.0) pins a layer by the middle of its bottom edge, which is useful when a video should grow from the bottom.

Layers

The layer constructors are text, rect, ellipse, image, and video. Each returns a Node. Add it to the composition or a group with add, which returns the same node for chaining.

ts
const title = comp.add(text("Title", { size: 80, weight: 700, color: "#fff" }));
title.x(960).y(300);

Groups

A group is a container whose transform applies to all its children. Children are placed in the group's own space, as offsets from its origin, so moving or scaling the group moves and scales the whole block as a unit.

ts
const card = comp.group({ x: 960, y: 540 });
card.add(rect({ size: [600, 320], radius: 16, fill: "#1B1535" })).x(0).y(0);
card.add(text("On the card", { size: 48, color: "#fff" })).x(0).y(0);
card.enter.pop({ from: 0.9, ease: { damping: 14 } });

Properties and transform

A node has five animatable properties: x, y, scale, rotation (in degrees), and opacity. Each is a handle you can drive three ways:

ts
node.scale(2);                              // a constant
node.y((frame) => 540 + Math.sin(frame / 12) * 20); // a frame expression
node.opacity.keys([                          // an explicit keyframe track
  { at: "0s", to: 0 },
  { at: "0.5s", to: 1, ease: "out" },
]);

A constant sets the value the property rests at, which is also the value an entrance settles into. A frame expression is evaluated once per frame and baked into the document, so the low-level spring, interpolate, and easings helpers can run inside it. A keyframe track is an explicit timeline; it takes over that property, so a verb on the same property is ignored.

anchor(x, y) sets the pivot. at(time) sets when the entrance begins.

Movement after the entrance comes from three tweens that animate toward a target:

  • moveTo(x, y, opts) to a position.
  • scaleTo(scale, opts) to a uniform scale.
  • rotateTo(degrees, opts) to a rotation.

Each starts at opts.at seconds (default 0) and runs for opts.duration (default 0.8s).

Combining animations

Animations on different properties run at once. Give two tweens the same at and they play together:

ts
node.scaleTo(1.5, { at: "0.5s", duration: "1s" })
    .rotateTo(180, { at: "0.5s", duration: "1s" }); // scale and rotate together

Animations on the same property run in sequence and chain: each tween continues from where the last ended, so set different at times to order them:

ts
node.moveTo(400, 100, { at: "1s" })   // slide right
    .moveTo(400, 400, { at: "2s" });  // then down, from where it stopped

For full control of any property over time, a keyframe track expresses any combination, concurrent or sequential, with its own start, duration, and easing per segment.

Perspective tilt (2.5D)

A drawn layer (text, shape, image, video) can tilt in space for a 2.5D look. Set rotationX or rotationY (degrees) for the tilt and perspective (a camera distance in pixels) for the foreshortening. Zero perspective is an orthographic tilt; a few hundred to a couple thousand pixels looks natural, smaller being stronger.

ts
comp.add(image("window.png", { size: [820, 500] }))
  .x(cx).y(cy)
  .rotationY(35)       // turn about the vertical axis
  .perspective(900);   // near edge larger, far edge receding

The tilt is per layer (it does not propagate from a group to its children), and it composes with the rest of the transform, so a layer can slide with moveTo while its tilt eases via a rotationY track. See the examples for a tilt combined with a slide.

Timing

Durations are written as a number of seconds or a string like "0.5s". Use at(time) to set when a layer's entrance begins, in seconds from the start of its scene (or the composition in duration mode).

ts
comp.add(text("Late", { size: 60 })).x(960).y(540).at("1.2s").enter.fade();

Motion

Entrance verbs live on node.enter, exit verbs on node.exit. The set is small: pop (scale up with a fade), rise (fade in while sliding up), slide (slide in from a direction with overshoot), and fade. Exits are rise and fade. Each returns a Transition whose start can be placed relative to another element.

ts
node.enter.pop({ from: 0.5, ease: { damping: 13 } });
node.enter.rise({ by: 22, ease: { damping: 200 } });
node.enter.slide({ from: "left", by: 64, ease: { stiffness: 220, damping: 12, mass: 0.5 } });
node.enter.fade();
node.exit.fade({ at: "4s" });

An entrance's motion is set by ease. The default is a spring; passing a spring object shapes it. Lower mass and higher stiffness give a faster, punchier entrance. Lower damping gives more overshoot. A high damping such as 200 settles smoothly with no overshoot. ease can also be a named curve (for example ease: "out"), in which case the entrance eases over duration instead of springing. This is the same ease used by keyframes and the tween verbs.

Relative timing

A Transition can start relative to another element, so a layout stays in sync when you retime it:

ts
const icon = comp.add(image("icon.png", { size: 120 })).x(900).y(540);
const word = comp.add(text("Sel", { size: 116, weight: 800 })).x(1080).y(540);
icon.enter.pop({ from: 0.5, ease: { damping: 13 } });
word.enter.slide({ from: "left", by: 20, ease: { damping: 200 } }).with(icon);     // together with the icon
const pill = comp.add(badge("Coming soon")).x(960).y(700);
pill.enter.pop().after(icon, "0.8s");                                     // 0.8s after the icon starts

Text alignment

align behaves in two ways, depending on whether maxWidth is set. Without maxWidth, it anchors to the x position: left puts the left edge at x, center straddles x, right puts the right edge at x. With maxWidth, the text gets a box that wide whose left edge sits at x, and align justifies the lines within it, so the text stays in place as you change alignment. Set maxWidth when you want justified, in-place alignment; leave it off to anchor a single line to a point.

Text reveals

A text node can be split into per-word, per-character, or per-line units with words(), chars(), or lines(). The result is a group you animate together, and stagger offsets each unit in order.

ts
comp.add(text("One word at a time", { size: 80, weight: 800, color: "#111" }))
  .x(960)
  .y(540)
  .words()
  .enter.rise({ by: 22, ease: { damping: 200 } })
  .stagger("0.08s");

Captions and badges

caption is a lower-third over footage: an uppercase eyebrow and a word-revealed title in a translucent rounded box that hugs the text, with an optional note. badge is a pill that auto-sizes around one line of centered text. Both return a group, so you place them with x/y and give them an entrance like any group.

ts
s.add(caption({ eyebrow: "Focus", title: "Zoom and highlight as you talk." }));
comp.add(badge("Coming soon to the Mac App Store")).x(960).y(620);

Audio

comp.audio(path, options) lays a music bed under the whole composition. gain is the held volume from 0 to 1; the volume ramps up over fadeIn and down over fadeOut.

ts
comp.audio("music.mp3", { gain: 0.85, fadeIn: "0.6s", fadeOut: "1.5s" });

Colors

A color is one of "#RRGGBB", "#RRGGBBAA", "rgb(r, g, b)", or "rgba(r, g, b, a)", with r, g, b from 0 to 255 and a from 0 to 1. Named colors and hsl(...) are not supported. gradient(from, to) makes a diagonal linear gradient usable anywhere a fill is accepted.