What is a Timeline?

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

Timelines let you write 3D behaviors like a story using async generator functions. 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 an init function that runs once before the first update (can be sync or async; updates are deferred until it resolves). Return a cleanup function to run on abort.
  • provide an update(state, clock) called every frame; return false to finish
  • provide an until 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') })
Important

Never use await ... inside timelines. Awaiting a promise pauses the generator in a way that cannot be canceled by the timeline runtime. Instead, wrap waits in an action and yield* it so cancellation can propagate correctly.

Bad:

await someAsyncThing()

Good:

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

How long does an action run?

An action runs until either:

  • its update returns false, or
  • its until promise resolves.

If both are provided, the action keeps updating until the until resolves.

The action clock

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

  • delta: seconds since last frame (internally clamped to ~1/30s)
  • prevDelta: previous delta or undefined on the first frame (used by spring/velocity to infer velocity)
  • actionTime: time since the current action started (resets between actions)

Timelines

A timeline is an async 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.

async 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
  • Race: yield* parallel('race', A, B) continues when the first finishes
yield * parallel('all', A, B)
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
async function* someReusableTimeline() {
  yield* action({ until: timePassed(0.5, 'seconds') })
}

// Compose inside another timeline
yield * someReusableTimeline()

Graph timelines

Model behaviors as named states and transitions. graph(initial, stateMap) drives the flow; useTimelineGraph is the React wrapper.

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

TimelineQueue

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 TimelineQueue()

// Manager timeline: repeatedly drain the queue, then wait for new work
async 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.add(async function* () {
  yield* action({ until: timePassed(0.5, 'seconds') })
})

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

// Clear pending items
queue.clear()
  • add(timeline, index?): enqueue at optional index (default appends). If the queue was empty, any waiters from waitUntilNotEmpty() resolve.
  • run(): async generator that yields each enqueued timeline in FIFO order until empty.
  • waitUntilNotEmpty(): promise that resolves immediately if there are entries, or when the next item is added.
  • 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.

TimelineGraph (class API)

Beyond the graph(initial, map) helper, you can control a state machine via the TimelineGraph class—add/remove states at runtime, or force transitions.

const machine = new TimelineGraph('Idle', {
  Idle: {
    timeline: () => action({ update: () => true }), // keep updating until a transition fires
    transitionTo: {
      Run: { whenPromise: () => 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.setState('Run')
machine.addState(
  'Attack',
  () => action({ until: timePassed(0.2, 'seconds') }),
  { finally: 'Idle' },
)
machine.removeState('Attack')

Transitions can be expressed with:

  • whenUpdate(state, clock) => boolean: transition when this returns true
  • whenPromise: () => Promise: transition when the promise resolves
  • finally: 'StateName': default next state after the state's timeline completes

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

async 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