Timeline
Timeline animates several targets on one clock — an intro, a cut-scene, a staggered list: tracks
on UI elements, 3D nodes, lights, the camera, 2D nodes and sprites at set times, plus JS calls and
labels. One element's style animates with its own animateTo / animateFrom
(styling); one
free value with animate().
- play an intro: the title fades in, then the hint
- make list items appear one after another
- play a cut-scene: move the camera, open a door, brighten the light
- run code at a point in an animation
- replay a sequence, loop it or jump to a named point
- pause a cut-scene while the game is paused
Platforms: every host for tracks on UI elements, tl.animate values and call; tracks on 3D
nodes, lights, the camera, 2D nodes and sprites play on iOS, Android, desktop and the web player —
the web skips them, and headless tests play only the 2D ones.
At a glance
const title = UIText('LEVEL 1').style({ fontSize: 40, color: 'white', opacity: 0 })
const hint = UIText('Tap to start').style({ fontSize: 18, color: '#9fb0d0', opacity: 0 })
UIScreen(title, hint).style({ alignItems: 'center', justifyContent: 'center', gap: 12 }).open()
const intro = Timeline()
.add(title, { opacity: 1, transform: ['translateY(24px)', 'translateY(0px)'], duration: 500, easing: 'outCubic' })
.label('shown') // = the current end: 500 ms
.add(hint, { opacity: [0, 1, 0.4, 1], duration: 900 }, { at: ['shown', -200] }) // starts at 300 ms
.call('shown', () => console.log('title in'))
intro.play()
intro.finished.then((done) => console.log('intro done', done)) // after 1200 msBuild a timeline: tracks at times, labels, call, loop
A timeline is built once and played through one Animation handle;
add takes the same props bag as a target's .animateTo().
Timeline(options?: {
clock?: 'ui' | 'game', // default 'ui', whatever the targets are
loop?: boolean | number, // repeat the whole timeline: true = forever, n = cycles
loopMode?: 'ping-pong' | 'restart', // default 'ping-pong'
delay?: number, // ms before the first cycle
}): Timeline
tl.add(target | targets[], props, { at?, stagger? }?): this // like target.animateTo(props)
tl.addFrom(target | targets[], props, { at?, stagger? }?): this // like animateFrom: the target's own state is the last key
tl.animate(options, at?): this // a free-value track — the options of animate()
tl.call(at, fn: () => void): this // run JS when playback crosses `at`
tl.label(name, at?): this // name a time; default = the current end
tl.hold(ms): this // stillness after the current end
tl.labels: Record<string, number> // named times, ms
tl.end: number // the current end, ms
type TimelinePosition = number | string | [label: string, offsetMs: number]- Every time is milliseconds from the timeline's start.
atis a number, a label, or[label, offset]; an unknown label throws. Withoutata track starts at the current end, so consecutiveaddcalls play one after another. propsis theanimateTobag: prop → value (one value tweens from the current one, an array is keyframes), plusduration(ms, default 225),delay(ms afterat),easing(default ease-in-out,cubic-bezier(0.42, 0, 0.58, 1)— see easings),times,commit.loop,loopModeandclockbelong to the timeline, not to a track.callrunsfnwhen playback crosses its time in either direction (a seek, a negativerate), in time order, before the timeline finishes; a call at 0 runs onplay().
onComplete of a tl.animate(…) track is not called — use tl.call(at, fn) or
tl.finished. onUpdate works as in animate().
Items one after another: stagger
With an array of targets, stagger starts each one that many ms after the previous.
const rows = ['Inbox', 'Starred', 'Sent', 'Drafts'].map((name) =>
UIText(name).style({ fontSize: 20, color: 'white', opacity: 0 }))
UIScreen(...rows).style({ bgColor: '#101418', padding: 24, gap: 12 }).open()
Timeline()
.add(rows, { opacity: 1, transform: ['translateX(-40px)', 'translateX(0px)'], duration: 300, easing: 'outCubic' },
{ stagger: 80 }) // 0, 80, 160, 240 ms
.play()What each target animates: UI, 3D nodes, lights, the camera, 2D {#what-each-target-animates}
| target | props |
|---|---|
| UI element | any style prop (opacity, width, bgColor, transform, …): numbers, lengths and colors interpolate, transform lists per function, other strings flip at the keyframe; text on text elements (a flip) |
3D node (Mesh, Model, Node) |
position, scale, quaternion (slerp), eulerAngles (degrees, per axis — [0, 720, 0] turns twice) |
Light |
the node props + intensity, color |
camera (scene.camera) |
the node props + fov (degrees), near, far |
Node2D |
x, y, position, rotation (degrees), scale |
Sprite |
the 2D node props + opacity, color |
A 3D / 2D target skips a prop it can't animate, with a one-time warning. See
Styling for how the UI props tween (units, colors, transform).
on the vector props — position, scale, quaternion, eulerAngles, and color
on 3D / 2D targets — an array of numbers is ONE value; keyframes are an array of vectors:
scale: [[1, 1, 1], [1.2, 1.2, 1.2], [1, 1, 1]]. scale: 1.5 is uniform.
a track writes a 3D node's transform directly. A node moved by a physics body or a character controller is not routed through its body — animate a plain node.
Replay, pause, seek: the timeline is an Animation
A timeline is an Animation — play(), pause(), resume(), seek(), finish(),
cancel(), time, progress, rate, finished: see
the Animation handle. seek also takes a label
(tl.seek('shown')).
- Nothing runs before
play().play()again restarts from 0 and settles the previous run'sfinishedwithfalse. - The pose at any time is fully defined by the tracks: on
play()every target jumps to its track's first keyframe — also when that track starts later — and after a track ends its last keyframe holds. - A channel belongs to the newest animation: playing a timeline takes the props it names from any older animation on the same targets; their other props keep running.
- A one-shot track commits its last keyframe on
play(): from then on a UI element'sstyle, a 2D node'sx/y/position/rotation/scale, a sprite'sopacity/color, a light'sintensityand a camera'snear/farread the final value while the pose is still moving. A 3D node's transform andcamera.fovread the live pose;light.colorkeeps reading the value it had before the tween.
A cut-scene on the game clock: camera, door, lights
a timeline runs on the 'ui' clock unless you pass clock: 'game' — even over 3D
nodes, whose own animateTo defaults to 'game'. The 'game' clock follows Time.scale /
Time.paused on a host with the 3D engine once a 3D Scene exists; see
Clocks.
const scene = new Scene()
const sun = Light.sun({ intensity: 20000 })
const door = Mesh.box({ material: Material.lit({ color: '#8a6' }), position: [0, 1, -6], scale: [2, 2, 0.2] })
const gems = [-2, 0, 2].map((x) => Mesh.sphere({ material: Material.lit({ color: '#4f7cff' }), position: [x, 0.5, -3], scale: 0.4 }))
scene.add(sun, door, ...gems)
scene.open()
const opening = Timeline({ clock: 'game' }) // stops while Time.paused
.add(scene.camera, { position: [[0, 2, 6], [0, 1.5, 1]], fov: 40, duration: 1500 }, { at: 0 })
.add(door, { position: [0, 3.2, -6], duration: 800, easing: 'inOutCubic' }, { at: 600 })
.add(gems, { eulerAngles: [0, 360, 0], duration: 600 }, { at: 900, stagger: 120 }) // one turn each
.add(sun, { intensity: 60000, color: '#ffd9a0', duration: 1200 }, { at: 300 })
.call(1500, () => console.log('cutscene over'))
opening.play()Pitfalls
// ✗ building it and waiting — a timeline does nothing until played
const fade = Timeline().add(card, { opacity: 1 })
// ✓ play it, again on every entrance
fade.play()
// ✗ numbers on a vector prop are ONE vector: this scales to (1, 1.2, 1)
Timeline().add(gem, { scale: [1, 1.2, 1] }).play()
// ✓ keyframes of a vector prop are vectors
Timeline().add(gem, { scale: [[1, 1, 1], [1.2, 1.2, 1.2], [1, 1, 1]] }).play()