What is a Timeline?
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
initfunction 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; returnfalseto finish - provide an
untilcondition 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,foreverandqueue.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.
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
updatereturnsfalse, or - its
untilcondition is met or itsuntilpromise 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 byrunTimelineprevDelta: the previousdelta,0on the first frame (used byspring/velocityto infer velocity)frame: index of the current update, starting at 0 (lets a finishedtransition/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 intouseFrameand restarts ondepschange. - Vanilla: use
runTimeline(reusableTimeline)to get an update function you call each frame withdeltaSeconds.
// 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 returnsfalseif one of them returnedfalse, otherwisetrue - 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():
untilcondition 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
actionTimebeing 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
timelinecompletes
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 liketimePassed,mediaFinished,animationFinished, plusdoUntil/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