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

TypeScript
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 ms

Build 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().

TypeScript
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. at is a number, a label, or [label, offset]; an unknown label throws. Without at a track starts at the current end, so consecutive add calls play one after another.
  • props is the animateTo bag: prop → value (one value tweens from the current one, an array is keyframes), plus duration (ms, default 225), delay (ms after at), easing (default ease-in-out, cubic-bezier(0.42, 0, 0.58, 1) — see easings), times, commit. loop, loopMode and clock belong to the timeline, not to a track.
  • call runs fn when playback crosses its time in either direction (a seek, a negative rate), in time order, before the timeline finishes; a call at 0 runs on play().
Note

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.

TypeScript
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).

Note

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.

Note

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's finished with false.
  • 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's style, a 2D node's x / y / position / rotation / scale, a sprite's opacity / color, a light's intensity and a camera's near / far read the final value while the pose is still moving. A 3D node's transform and camera.fov read the live pose; light.color keeps reading the value it had before the tween.

A cut-scene on the game clock: camera, door, lights

Note

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.

TypeScript
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

TypeScript
// ✗ 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()

See also

  • animate — free values, the Animation handle, clocks
  • Easings — curve names, cubicBezier, steps
  • Styling — .animateTo() / .animateFrom() and the style props of elements
  • Time — Time.scale / Time.paused