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);

caption and badge are themselves built from the component tools below.

Components

A component is a function that builds a piece once and returns it. There is no re-rendering and no reactive state: it is a factory that runs at build time, the same kind of thing caption and badge already are. It is the way to reuse a layout, such as a lower-third, a chart bar, or a leaderboard row.

Three pieces support it:

  • group() returns a detached group, the building block. Unlike comp.group(), it is not added on creation. The component fills it and returns it; the caller adds it with comp.add and positions it.
  • node.name(label) names a part, so the group can reach it with find and the Studio shows the name in the layer list and timeline.
  • component(name, build) wraps the builder. Each call makes one fresh instance, tagged with name, so a fleet of them reads as named lanes in the Studio.
ts
const Row = component("Row", ({ label }: { label: string }) => {
  const g = group();
  g.add(rect({ size: [560, 64], radius: 12, fill: "#1B1535" })).name("plate");
  g.add(text(label, { size: 30, color: "#fff" })).name("label");
  g.enter.slide({ from: "left", by: 90 });   // the component animates itself
  return g;
});

data.forEach((d, i) => {
  comp.add(Row({ label: d.name })).x(960).y(300 + i * 80).at(`${0.15 * i}s`);
});

The caller decides only where each instance sits and when it starts. Parts stay reachable: instance.find("label").enter.rise(). Components nest, since one returns a node like any other, so a larger piece can g.add(Row(...)) or g.add(badge("Live")). A required prop that is missing should throw, so a misuse fails loudly rather than rendering an empty piece.

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" });

Motion blur

Set motionBlur in the composition config to smear fast motion the way a camera shutter does. Each output frame is the average of several sub-frames sampled across the shutter interval, so any movement (position, scale, rotation, a morph) blurs.

ts
composition({ width: 1920, height: 1080, fps: 30, duration: "5s", motionBlur: true }, (comp) => {
  // ...
});

true uses the natural defaults: a 180 degree shutter and 16 samples. Tune them with an object:

ts
motionBlur: { shutter: 180, samples: 16 }

shutter is the exposure as a shutter angle in degrees, the film convention: 180 exposes half the frame interval (the natural amount), lower is crisper, higher smears more. samples is how many sub-frames are averaged: higher is smoother and costs more to render.

It applies to every moving layer. Exclude one (for example a background you want to stay sharp) with node.motionBlur(false).

In the studio, the preview does not blur until you turn on the motion-blur toggle in the toolbar, so scrubbing stays responsive; with it on, playback uses a reduced sample count and a paused frame renders at full quality. Export always uses the composition's full settings. Lottie export cannot represent motion blur, so it is dropped there with a warning.

Layer effects

Blur is a uniform effect available on any layer, the same way opacity and blend are. There are two, expressed as verbs:

ts
node.blur(16);          // soften the layer's own pixels
node.backdropBlur(24);  // frosted glass: blur the content seen through it

blur Gaussian-blurs the layer itself, for a soft element or a glow. It works on any layer: a shape, text, an image, an SVG. backdropBlur is frosted glass: it blurs whatever is drawn below the layer and shows it through, tinted by the layer's fill. It applies to shapes (rect, ellipse), whose outline is the mask, so a translucent rounded rectangle over content reads as frosted glass.

Both are dropped on Lottie export, with a warning, since the format cannot represent them.

Colors

A color is one of "#RGB", "#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 accepted in a script; an unrecognized color is a build error, not a silent black, so a typo is caught.

gradient(from, to) makes a two-stop linear gradient. For more, gradient.linear and gradient.radial take a list of stops and geometry in fractions of the shape's box:

ts
fill: gradient.radial([[0, "#FFD36E"], [1, "#E0604F00"]], { center: [0.5, 0.5], radius: 0.5 })
fill: gradient.linear([[0, "#2BD4A8"], [1, "#1E90FF"]], { from: [0, 0], to: [1, 0] })

A stop is [offset, color] or { offset, color }; the color may carry alpha. A gradient is accepted anywhere a fill is.

Vector graphics and morphing

loadSVG(path) imports an SVG file as a group of vector shapes, parsed once. The returned group is a normal layer: add it with comp.add to draw it, centered on the canvas. comp.svg(path) is the create-and-add shorthand, the same way comp.group() relates to building a group and adding it. The whole drawing animates through the group, and each shape is reachable for its own animation:

ts
const logo = comp.add(loadSVG("logo.svg"));  // or: const logo = comp.svg("logo.svg");
logo.scaleTo(2, { at: "0.3s" });          // animate the whole drawing
logo.find("arm").rotateTo(-20, { at: "0.5s" });  // reach one shape by its SVG id
logo.child(0).fill("#E0604F");            // or by index, and recolor it

find(name) searches by the SVG element id and errors if none matches, listing the ids that exist; child(index) indexes by document order. A <g id="..."> group is preserved and addressable too, so find("circInner") returns that sub-group and you animate the whole cluster as a unit; it pivots about its own center. A shape recolors with fill and stroke. drawOn reveals a stroke from nothing to its full length; trim shows a fixed window of the path. Unrecognized colors and dropped SVG features (gradients on import are supported; filters, masks, and text are not) are reported in the studio log rather than failing silently.

A shape can morph into another outline. morph runs through a sequence; morphTo is a single step, and chaining morphTo builds a timed sequence. A target is a builder (rect, ellipse), another layer, or an SVG group from loadSVG(path). The same loadSVG group can be drawn with comp.add or used as a morph target without being drawn. Every shape keeps its own size, so the target ends at its own proportions:

ts
const e = comp.add(ellipse({ size: 400, fill: "#2BD4A8" }));
e.morphTo(rect({ size: 400 }), { at: "0.5s", duration: "0.5s" })
 .morphTo(loadSVG("cat.svg", { size: 300 }), { at: "1s", duration: "1s" });

Morphing matches the outer outline, so a shape with holes or several pieces morphs its largest contour. size: sets how big a shape is; scale only multiplies it, so size a shape with size: and use scale for motion.