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
initfunction 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; returnfalseto finish - provide an
untilpromise 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') })
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
updatereturnsfalse, or - its
untilpromise 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: previousdeltaorundefinedon the first frame (used byspring/velocityto 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 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 - 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
timelinecompletes
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 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