---
name: motion-principles
description: Choose timing, easing, stagger, anticipation, and follow-through for a NodeTool timeline. Use when motion feels stiff, floaty, busy, or late, or when an entrance, transition, or beat-bound animation needs a duration and curve. The op contract lives in timeline-edit-ops; the build loop is motion-graphics.
featured: true
---

# Motion Principles → the numbers before the call

`timeline-edit-ops` is how to write an animation onto a clip with
`edit_timeline`. This is how to decide what to write. Every number here is in
the units `animate_clip` takes: milliseconds, a normalized 0..1 distance, an
easing string.

## The same numbers, written as code

Building with `@nodetool-ai/sandbox-timeline` instead (`motion-graphics`
picks the surface)? Every number below still applies — only the unit
changes. `durationMs` becomes `dur` in seconds (divide by 1000: 400ms is
`dur: 0.4`). A normalized 0..1 distance becomes actual px, against the
frame's own `width`/`height` (0.3 on a 1920-wide frame is `offsetX: 576` in
`from`). Easing strings are identical — `ease: "outExpo"` or a raw curve —
and every role still means the same thing: `el.enter()` is `in`, `el.exit()`
is `out`, `el.animate()` covers `emphasis` and general keyframing,
`el.loop()` is `loop`. `el.count()`, `el.draw()`, `el.scramble()`,
`el.typewriter()`, `el.expr()` and `el.morph()` have no ops equivalent
below; they are shortcuts for a ticker, a `trimEnd` draw-on, a scramble
reveal, a whole-clip typewriter entrance, a hand-written keyframe function,
and a path outline morph.

## Settle three things before picking a number

| Pillar | The question | What it decides |
|---|---|---|
| Emotional intent | What should the viewer feel? | Easing, duration, overshoot |
| Visual narrative | Setup, action, resolution — what enters when? | Sequencing, `delayMs`, stagger |
| Motion craft | Why does it look believable? | Anticipation, follow-through, arcs |

A number chosen before the intent is a number you will change twice.

## Three layers, or the frame reads flat

Plan three levels of attention. Track index controls their z-order, while
timing decides which one the eye follows:

- **Primary** — the one move the eye follows. The clip the shot is about.
- **Secondary** — supporting motion reacting to the primary: a scrim settling
  late, a label arriving 80ms after the plate.
- **Ambient** — background life that never asks for attention. A `loop` preset
  at low amplitude on the bed.

`motion-direction` ranks the same three as Hero / Support / Texture and decides
which elements earn each. Two clips carrying primary motion at once is the most
common cause of a busy frame.

## Easing is the biggest lever

`linear` reads as robotic. Keep it for motion that never lands: the `loop`
presets `float`, `rotate` and `hueShift`, and the `shake` emphasis, whose
zig-zag is linear by default. Everything the eye reads as an event decelerates
into rest.

| Action | Easing | Why |
|---|---|---|
| Entrance (`in`) | `easeOut`, or `cubic-bezier(0.16,1,0.3,1)` for a sharper landing | Arrives with energy, settles |
| Exit (`out`) | `easeIn`, or `cubic-bezier(0.7,0,0.84,0)` | Accelerates away |
| A move that stays on screen | `easeInOut`, or `cubic-bezier(0.65,0,0.35,1)` | Symmetric, no false weight |
| Playful landing | `easeOutBack`, or `cubic-bezier(0.34,1.56,0.64,1)` | The value past 1 is the overshoot |
| Physical settle | `spring(180,12,1)` one overshoot, `spring(180,26,1)` none | Solves a real spring; all three constants positive |
| Held pose, then jump | `hold` on the ending keyframe's segment | Keeps the prior value until the segment ends |

The role defaults already do the right thing: `in` gets `easeOut`, `out` gets
`easeIn`. Leave `easing` unset unless you want something the default is not.
A string the parser cannot read eases linearly and reports `unknown_easing` —
`easeOut`, never `ease-out`.

Overshoot reads as toy-like. Keep `easeOutBack`, `easeOutElastic` and
`easeOutBounce` off anything financial, medical or corporate.

## Duration carries weight

| Element | `durationMs` |
|---|---|
| Micro beat: a `flash`, a `pulse` on a small element | 150–250 |
| Title, lower third, overlay entrance | 300–500 |
| Hero element, full-frame plate, a `blur` rack | 500–800 |
| Camera move: `kenBurns`, a slow push | 1500–4000 |

Distance and area scale it. A caption travelling `distance: 0.05` and a
full-bleed plate travelling `0.5` must not share 400ms — the plate looks
weightless. Add roughly 30–50% when the travel or the area doubles.

Two limits the engine enforces rather than warns about in prose:

- `in` + `out` must fit the clip with hold left over. A 3000ms title with 500ms
  each way holds 2000ms. Under about 1000ms of hold the element never settles
  and reads as a glitch. Over-long windows report `animation_exceeds_clip`.
- A `loop` cycle longer than the clip shows a fragment. `kenBurns` at its 3000ms
  default on a 2000ms clip is a drift that stops halfway.

## Stagger is rhythm, and it is clamped

Never reveal a group in one event. On a text clip, `stagger` splits the
animation per word, grapheme or wrapped line. Which `unit` counts what, and how
the count changes with the frame, is in `motion-graphics`.

| Content | `offsetMs` |
|---|---|
| A phrase that should arrive with texture | 60–100 |
| A short punchline landing word by word | 150–250 |
| More than five words | do not stagger — the reader is waiting on the sentence |

The span is `durationMs + offsetMs × (units − 1)`, halved for `from: "center"`.
Work it out and compare it against the clip's `durationMs` minus `delayMs`. A
span that does not fit is **compressed silently**: the engine shrinks the offset,
never the per-unit duration, so the line lands flatter than written and only
`validate_timeline` says so, as `stagger_compressed`.

For a group of separate clips — three lower-third parts, five icons — stagger by
giving each clip's `in` animation a `delayMs` 60–80ms apart, and cap the whole
reveal near 700ms. Past that the tail drags.

## The 1/3 rule, in both forms

- **Distance.** Nothing travels more than about a third of the frame in one
  unbroken move. `slide` with `distance` above 0.35 reads as "the template
  moved". Break it with a scale or opacity change on the same window, or shorten
  the travel.
- **Simultaneity.** With three or more elements, keep at most a third in active
  motion at any instant. The rest hold, or run ambient loops.

## Anticipation, follow-through, arcs

The three that separate animated from moved. None has a preset; each is a
`custom` animation, which is where a short `code` body earns its place.

**Anticipation** — a counter-move of 60–120ms before the main action. Ask for a
dip before a pop:

```json
{"role": "in", "preset": "custom", "durationMs": 520,
 "curves": [{"property": "scale", "keyframes": [
   {"t": 0, "value": 1}, {"t": 0.2, "value": 0.94, "easing": "easeInOut"},
   {"t": 0.75, "value": 1.06, "easing": "easeOut"}, {"t": 1, "value": 1}]}]}
```

**Follow-through** — attached parts settle 40–80ms after the body. Give the
child clip the same preset and a `delayMs` 60ms later, rather than animating the
whole group as one block.

**Arcs** — straight-line translation of anything organic looks mechanical. Drive
`offsetX` linearly and `offsetY` as a shallow curve over the same window; the
two fold as `add`, so they compose.

A decaying bounce, an arc, or a settle written as a function of time is a `code`
body returning `samples` — one bag per point, `{t, offsetY}`, at
`inputs.sampleCount` points. The body runs once at author time, hermetically, and
reads `role`, `durationMs`, `clipDurationMs`, `canvasWidth`, `canvasHeight`,
`params`, `staggerCount` and `sampleCount` off `inputs`:

```js
// fragment
const n = inputs.sampleCount;
const h = 0.06 * inputs.canvasHeight;
const samples = [];
for (let i = 0; i < n; i++) {
  const t = i / (n - 1);
  // three decaying hops, ending exactly at rest
  const y = -h * Math.abs(Math.sin(Math.PI * 3 * t)) * (1 - t);
  samples.push({ t, offsetY: y });
}
return { samples };
```

Land the last sample on the identity value — 0 for an offset, 1 for a scale or
an opacity — when the motion should settle. An `in` curve contributes nothing
after its window, an `out` curve holds its last value after the window, and an
`emphasis` curve returns to its base outside the window.

## Rhythm against audio

Land impact keyframes on the beat. At 120 BPM a beat is 500ms and the eighth
grid is 250ms. `beat-sync-editing` turns detected onsets into markers and
snaps. For motion that should follow later tempo edits, store `beat` on the
animation in the full timeline document. Its `index` is one-based, `scope`
is `clip` or `sequence`, and `offsetMs` can place anticipation before the hit.
`edit_timeline`'s `animate_clip` input does not author this field; use
`get_timeline` and `set_timeline_document` as described in `motion-graphics`.

```json
{"id":"impact","role":"emphasis","preset":"pulse","durationMs":240,"beat":{"index":3,"scope":"sequence","offsetMs":-20}}
```

A move ending 80ms after a hit reads late, and one ending 80ms before it can
weaken the impact. Use `offsetMs` deliberately for anticipation.

With no music, keep one major event per 500–800ms so the piece breathes at a
constant pulse.

## Spring or duration

`spring(stiffness,damping,mass)` shapes the motion inside the animation's
`durationMs`; it is scaled to settle near the end of that window. Use it for a
physical-looking landing, then set `durationMs` against the cut as you would
for any other easing. For a precise held pose or hard timecode, write
keyframes and put `hold` on the segment that should stay still.

## Frame texture

Use per-clip `motionBlur` when a fast move strobes. Start with a 180-degree
shutter and raise `samplesPerFrame` only as far as the frame needs; the highest
requested sample count is shared by the scene. Use `steppedTime.fps` for a
deliberate low-frame-rate look, or `temporalEcho` for delayed copies. These are
stored clip fields written through `set_timeline_document`, not
`animate_clip` parameters.

## Diagnosing

| It feels | Because | Fix |
|---|---|---|
| Stiff, robotic | `linear` or a symmetric curve on an entrance | `easeOut`, or drop `easing` and take the role default |
| Floaty, slow | Duration too long, curve too gentle | Cut 30%, sharpen to `cubic-bezier(0.16,1,0.3,1)` |
| Cheap, janky | Everything arrives at once, one duration for every size | Stagger, and scale duration with area |
| Mechanical despite easing | No anticipation, no overlap, straight-line paths | A `custom` dip before the move; 60ms offsets on attached parts |
| Busy | Two primary moves in one frame | Demote one to a loop or hold it still |
| Staggered text does not look staggered | Span exceeded the clip and was compressed | Lengthen the clip or lower `offsetMs` yourself |

## Quick reference

| Need | Value |
|---|---|
| Entrance | `easeOut` / `cubic-bezier(0.16,1,0.3,1)`, 300–500ms |
| Exit | `easeIn` / `cubic-bezier(0.7,0,0.84,0)`, 200–300ms |
| Move in place | `easeInOut`, 300–400ms |
| Playful landing | `easeOutBack`, 400–600ms |
| Word stagger | 60–100ms, five words or fewer |
| Clip-to-clip stagger | 60–80ms `delayMs` steps, whole reveal ≤ 700ms |
| Continuous loop | `linear` when constant speed is intended |
| Travel cap | `distance` ≤ 0.35 without a second channel |
