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 update(state, clock) called every frame; return false to finish
  • provide an until promise to decide when it finishes
  • or both to run something every frame until another event happens

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 useRunTimelinereusableTimeline, 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