What is a Timeline?

Learn the core concepts—actions, updates, until, orchestration, and parallelism.

Timelines let you write 3D behaviors like a story using generator functions (function*). You yield actions, wait for conditions, and compose sequences, loops, and parallel flows in a readable way.

At a high level, timelines are composed of actions and sub‑timelines. You chain them sequentially or run them in parallel to build rich behaviors.

Actions

An action is a unit of work you yield* from a timeline. It can:

  • provide a synchronous init function that runs when the action starts. Return a cleanup function to run when the action ends or is canceled. For async setup, wait for it first and keep the result: yield* action({ until: load().then((value) => (model = value)) }).
  • provide an update(state, clock) called every frame; return false to finish
  • provide an until condition or promise to decide when it finishes
  • or any combination of the above

Use an action when you want to perform work over time, wait for something to happen, or both.

What does an action look like?

yield *
  action({
    update: (state, clock) => {
      // apply changes every frame
      return false // finish immediately
    },
  })

Waiting with until

yield * action({ until: timePassed(1, 'seconds') })

until accepts either

  • a synchronous condition (state, clock, actionTime) => boolean, checked at the start of every frame (timePassed, forever and queue.waitUntilNotEmpty() return one), or
  • a promise for external events (mediaFinished, animationFinished, a click, a network request).

Timelines only advance inside the per-frame update call, so everything is stepped by frames and simulation time: timePassed counts the sum of frame deltas, not wall clock time. A promise is noticed on the next frame after it resolves (at most one frame of latency), so prefer conditions for sequencing that happens inside your simulation and promises for events from the outside world.

Important

Timelines are synchronous generators, so await is not available inside them. Wrap waits in an action and yield* it:

yield* action({ until: someAsyncThing() })

How long does an action run?

An action runs until either:

  • its update returns false, or
  • its until condition is met or its until promise resolved.

If both are provided, the action keeps updating until the until is met.

When an action finishes, the timeline immediately continues to its next action within the same frame. The next action receives its first update in that same frame only if the finished action did not use it (its until was already met at the start of the frame); otherwise it starts updating on the next frame. Three actions whose update returns false therefore run on three consecutive frames.

The action clock

The clock passed to update(state, clock, actionTime, memo) contains:

  • delta: seconds since the last frame, as passed to the update function returned by runTimeline
  • prevDelta: the previous delta, 0 on the first frame (used by spring/velocity to infer velocity)
  • frame: index of the current update, starting at 0 (lets a finished transition/lookAt/offset* hand its motion to the next action on the same target in the following frame)

The third argument, actionTime, is the time since the current action started (it resets between actions).

Timelines

A timeline is a generator that yields actions (and can yield other timelines). You can loop, wait, and branch—writing behavior like a story.

Reusable timelines

A reusable timeline is a function that returns a timeline so it can be started many times.

function* reusableFadeIn() {
  yield* action({ until: timePassed(0.5, 'seconds') })
}

// later inside another timeline
yield * reusableFadeIn()

Running timelines

  • React: use useRunTimeline(reusableTimeline, deps); it wires into useFrame and restarts on deps change.
  • Vanilla: use runTimeline(reusableTimeline) to get an update function you call each frame with deltaSeconds.
// React
useRunTimeline(() => reusableFadeIn(), [someDep])

// Vanilla
const update = runTimeline(reusableFadeIn)
// in your render loop
update(state, deltaSeconds)

Orchestration

You can chain actions/timelines sequentially or run them concurrently.

Sequential chaining

  • Sequential: yield* action(A); yield* action(B); runs A, then B.
// Sequential
yield * action(A)
yield * action(B)

Parallel composition

  • All: yield* parallel('all', A, B) continues when both finish and returns false if one of them returned false, otherwise true
  • Race: yield* parallel('race', A, B) continues when the first finishes and returns what that one returned
const bothSucceeded = yield * parallel('all', A, B)
const firstOutcome = yield * parallel('race', A, B)

Sub-timelines

Timelines compose: you can yield* another reusable timeline anywhere. This keeps complex behaviors modular and readable.

// Reusable timeline
function* someReusableTimeline() {
  yield* action({ until: timePassed(0.5, 'seconds') })
}

// Compose inside another timeline
yield * someReusableTimeline()

Resources and cancellation

When a timeline is canceled (a race is lost, a graph state is left, the abort signal passed to runTimeline aborts), its finally blocks run synchronously. Use try/finally to bind a resource to a timeline's lifetime:

function* withSound() {
  const audio = new Audio('./sound.mp3')
  try {
    yield* action({ until: audio.play() })
    yield* action({ until: mediaFinished(audio) })
  } finally {
    audio.pause()
  }
}

Graph timelines

Model behaviors as named states and transitions. graph(initial, stateMap) drives the flow; <Graph> and <GraphState> are the React components.

yield* graph('Idle', {
  Idle: {
    timeline: () => action({ update: ... }),
    transitionTo: { Run: { whenUpdate: timePassed(1, 'seconds') } }
  },
  Run: {/* ... */},
})

QueueTimeline

Use a queue to sequence reusable timelines that are added dynamically. The queue drains in order until empty each time you run it.

const queue = new QueueTimeline()

// Manager timeline: repeatedly drain the queue, then wait for new work
function* queueManager() {
  while (true) {
    // runs each enqueued timeline sequentially until the queue is empty
    yield* queue.run()
    // pause until someone enqueues again
    yield* action({ until: queue.waitUntilNotEmpty() })
  }
}

// Somewhere else: enqueue work
queue.attach(function* () {
  yield* action({ until: timePassed(0.5, 'seconds') })
})

// Insert at a specific position (e.g. front of the queue)
queue.attach(function* () {
  /* ... */
}, 0)

// Clear pending items
queue.clear()
  • attach(timeline, index?): enqueue at optional index (default appends).
  • run(): timeline that runs each enqueued timeline in FIFO order until empty (throws when run concurrently).
  • waitUntilNotEmpty(): until condition that is met while the queue has entries.
  • length: current number of queued timelines.

This is useful for input-driven behaviors (clicks, network events) where you want actions to play back-to-back without interleaving.

GraphTimeline (class API)

Beyond the graph(initial, map) helper, you can control a state machine via the GraphTimeline class—attach/unattach states at runtime, read the current state, or force transitions.

const machine = new GraphTimeline('Idle', {
  Idle: {
    timeline: () => action({ update: () => true }), // keep updating until a transition fires
    transitionTo: {
      Run: { whenUpdate: timePassed(1, 'seconds') },
    },
  },
  Run: {
    timeline: () => action({ until: timePassed(0.5, 'seconds') }),
    transitionTo: { finally: 'Idle' }, // jump here after timeline completes
  },
})

// Start the graph inside a timeline
yield* machine.run()

// At runtime, you can manipulate it:
machine.getState() // 'Idle'
machine.setState('Run') // cancels the current state; 'Run' starts on the next frame
machine.attach('Attack', () => action({ until: timePassed(0.2, 'seconds') }), { finally: 'Idle' })
machine.unattach('Attack')

Transitions can be expressed with:

  • whenUpdate(state, clock, actionTime) => boolean: transition when this returns true; checked at the start of every frame with actionTime being the time the state ran before this frame, so the next state receives that frame (e.g. timePassed(1, 'seconds'))
  • whenPromise: () => Promise: transition on the next frame after the promise resolved
  • finally: 'StateName': default next state after the state's timeline completes

A state's timeline may also return a state name to transition to:

function* Run() {
  // ... do work ...
  return 'Idle' // explicit next state
}

Built‑ins for motion

  • Action updates: transition, lookAt, offsetDistance, offsetRotation
  • Eases: spring(preset|config), time(durationSec), velocity(speed, maxAccel?)
  • Helpers: worldSpace(type, object), property(object, key), waits like timePassed, mediaFinished, animationFinished, plus doUntil/doWhile

Explore full, runnable examples in the tutorials:

  • Building your First Timeline
  • Building Complex Timelines using Parallel
  • Building Graph Timelines
  • Use pmndrs/timeline in vanilla three.js