These docs describe the main branch, including unreleased changes. Read the 0.8.0 docs.

Animation and Timeline

OpenTUI animates mutable numeric properties with Timeline. Each renderer can own an independent timeline engine.

Use a timeline for numeric renderable properties or application state. The API mutates each target directly.

Animate a renderable

Pass the renderer when you create a registered timeline.

import { BoxRenderable, createCliRenderer, createTimeline, getTimelineEngine } from "@opentui/core"

const renderer = await createCliRenderer()
const engine = getTimelineEngine(renderer)
const root = renderer.root

const box = new BoxRenderable(renderer, {
  id: "moving-box",
  position: "absolute",
  left: 0,
  top: 1,
  width: 8,
  height: 3,
  backgroundColor: "blue",
})
root.add(box)

let finish!: () => void
const finished = new Promise<void>((resolve) => {
  finish = resolve
})

const timeline = createTimeline(
  {
    duration: 600,
    autoplay: false,
    onComplete: finish,
  },
  renderer,
)

timeline.add(box, {
  left: 30,
  duration: 600,
  ease: "outQuad",
})

timeline.play()

try {
  await finished
} finally {
  timeline.pause()
  engine.unregister(timeline)
  renderer.destroy()
  await renderer.closed
}

The engine calls renderer.requestLive() and renderer.dropLive() as registered timelines start and stop. Do not call requestLive() for a registered timeline.

See Renderer live rendering for the shared live counter.

Imports

Core exports the class, factory, renderer-owned engine lookup, default engine, and public types from the package root:

import {
  Timeline,
  createTimeline,
  getTimelineEngine,
  engine,
  type AnimationOptions,
  type EasingFunctions,
  type JSAnimation,
  type TimelineOptions,
} from "@opentui/core"

Framework hooks use their framework package roots:

import { useTimeline } from "@opentui/react"
import { useTimeline } from "@opentui/solid"

Choose a constructor

Construction and registration are separate behaviors.

APIInitial stateRegistration
new Timeline(options?)Always paused, including autoplay: trueNot registered
createTimeline(options?, renderer?)Plays unless autoplay is falseUses the renderer's engine, or engine when omitted
React or Solid useTimeline(options?)Plays on mount unless autoplay is falseRegisters with the component's renderer engine on mount

new Timeline() stores the autoplay option but does not act on it. When you use the class directly, call play() and getTimelineEngine(renderer).register(timeline).

createTimeline() calls play() before it registers the timeline. Registration then updates the renderer's live state.

The framework hooks create the timeline during framework setup. Their mount handlers play and register it.

Timeline options

TimelineOptions fieldDefaultBehavior
duration1000Timeline cutoff in milliseconds
frameRateEngineUpdates per second, above 0; see Animation ticks
loopfalseRestarts the timeline at its cutoff only when exactly true
autoplaytrueUsed by the factory and framework mount handlers
onCompleteNoneRuns when a non-looping timeline reaches its cutoff
onPauseNoneRuns on every pause() call

duration: 0 falls back to 1000 because the constructor uses a truthy fallback. Use a positive finite duration.

The timeline duration does not derive from its items. A shorter timeline cuts off a longer animation.

A longer timeline stays active after all its items finish. Its onComplete runs only at the timeline cutoff.

Add numeric animation

add(target, properties, startTime?) accepts one target or an array of targets. It returns the same timeline.

Additional numeric keys in properties are animation end values. The timeline ignores non-numeric end values.

When the item first becomes active, OpenTUI reads each matching top-level target property. It captures only existing numeric values.

The target properties must remain writable numbers. A missing or non-numeric initial property can produce NaN, and a read-only property can throw.

Initial values stay captured across timeline loops and restart(). The timeline does not recapture them on each run.

The timeline does not support nested property paths, arrays of component values, or object traversal. Animate a top-level numeric property instead.

AnimationOptions

FieldTypeBehavior
durationnumberItem duration in milliseconds. Required by the TypeScript interface
easeEasingFunctionsNamed easing function. Default is linear
onUpdate(animation: JSAnimation) => voidRuns for each active update
onComplete() => voidRuns after the final item cycle
onStart() => voidRuns when the item first becomes active
onLoop() => voidRuns when an intermediate item cycle changes
loopboolean | numberInfinite cycles for true, or a numeric cycle count
loopDelaynumberDelay after each item cycle. Default is 0
alternatebooleanReverses every odd item cycle. Default is false
oncebooleanRemoves the item after completion. Default is false
Other numeric keysnumberEnd values for target properties

Untyped code that omits item duration currently falls back to 1000. TypeScript callers must supply it.

Use a positive integer for numeric loop. false, undefined, and 1 all run one cycle.

Update callback data

onUpdate receives a JSAnimation object:

FieldMeaning
targetsThe normalized target array after property mutation
deltaTimeThe update delta supplied for this evaluation
progressEased forward progress before alternate reverses assignment
currentTimeCurrent time of this timeline

Back and elastic easing can make progress less than 0 or greater than 1. OpenTUI clamps the easing input itself to 0..1.

Callbacks run synchronously inside update(). A thrown error propagates to the caller of update().

In an engine update, the error stops that update. Later items and timelines do not update in that frame. The renderer catches the error from the engine's frame callback, logs it, and continues the frame.

Easing names

EasingFunctions accepts exactly these names:

  • linear

  • inQuad

  • outQuad

  • inOutQuad

  • inExpo

  • outExpo

  • inOutSine

  • outBounce

  • outElastic

  • inBounce

  • inCirc

  • outCirc

  • inOutCirc

  • inBack

  • outBack

  • inOutBack

The API does not accept a custom easing function through ease.

Schedule items

Use numeric start times in milliseconds.

timeline.add(firstTarget, { x: 20, duration: 300 }, 0)
timeline.call(() => startSecondPhase(), 300)
timeline.add(secondTarget, { opacity: 1, duration: 200 }, 300)

add() and call() accept number | string at runtime. Every string currently resolves to time 0.

Strings do not name labels or relative positions. Use finite non-negative numbers for predictable scheduling.

call(callback, startTime?) runs the callback once when timeline time reaches its start. A loop or restart resets that execution flag.

once(target, properties) adds an animation at the current timeline time. OpenTUI removes that item after its completion.

Normal completed items and callbacks remain in items. Only a completed once animation removes itself.

Timeline methods

MethodBehavior
add(target, properties, startTime = 0)Adds a numeric animation and returns this
once(target, properties)Adds a removable animation at currentTime and returns this
call(callback, startTime = 0)Adds a scheduled callback and returns this
sync(timeline, startTime = 0)Gives this timeline scheduled control of one child
play()Starts or resumes. A completed timeline restarts
pause()Pauses this timeline and all synced children
restart()Sets time to zero, resets item flags, and starts playback
resetItems()Resets item and child flags without changing parent time or play state
update(deltaTime)Evaluates synced children, items, loops, and completion
addStateChangeListener(listener)Adds a play-state listener used by the engine
removeStateChangeListener(listener)Removes matching play-state listeners

pause() invokes onPause even when the timeline is already paused. It also calls pause() on every synced child.

restart() does not restore target properties immediately. The next active update calculates values from the captured initial values.

play() on a completed non-looping timeline calls restart(). play() on a paused incomplete timeline keeps currentTime.

update() expects elapsed milliseconds. An attached engine supplies its renderer's frame deltas, but tests and headless code can call it directly.

Use finite non-negative values for deltaTime, item duration, loopDelay, and start times. The current implementation does not validate these timing values.

A large delta evaluates each item once for that update. It can skip intermediate onUpdate and onLoop calls across multiple cycles.

For a looping timeline, OpenTUI keeps only the final duration overshoot. Large deltas do not replay every skipped parent loop.

Timeline state

Timeline exposes these mutable public fields:

FieldMeaning
itemsAnimation and callback items
subTimelinesSynced-child items
currentTimeCurrent parent time in milliseconds
isPlayingWhether parent items advance
isCompleteWhether a non-looping parent reached its cutoff
durationParent cutoff
loopParent loop flag
syncedWhether another timeline claimed this timeline as a child

Prefer the methods for state transitions. Direct item-array mutation relies on internal item shapes that OpenTUI does not export.

Sync child timelines

parent.sync(child, startTime) starts the child when the parent reaches that time. The child receives any overshoot from the crossing update.

Later parent updates pass their full delta to the child. Parent loops reset and restart child scheduling.

A parent pause pauses children that already started. A later parent play resumes those started children.

You can sync a timeline only once. A second claim throws exactly Error("Timeline already synced").

Sync has no inverse operation. The child's synced flag remains true, even after engine unregistration.

The engine skips a registered timeline when synced is true. Its parent becomes the only normal update owner.

Current limitation: update() evaluates synced children before it checks whether the parent is playing. A paused parent can start a future child when another manual or engine update crosses its start time.

Do not depend on a pending child staying idle after its parent pauses. Unregister the parent during the pause, or add the child after resume.

Engine ownership

getTimelineEngine(renderer) returns the engine for that renderer. It throws when the renderer is destroyed. It also restores the engine's frame callback if application code cleared renderer callbacks. Renderer destruction unregisters its timelines and detaches the engine.

The exported engine is the default engine. createTimeline() uses it when you omit the renderer. It is separate from renderer-owned engines.

engine.attach(renderer) moves the default engine to that renderer. React createRoot(renderer).render(), Solid render(), and Solid testRender() call it. The renderer from the most recent call drives timelines that you create without a renderer. While the default engine is not attached, nothing updates its timelines. Renderer destruction detaches the default engine but keeps its timelines.

Detach a renderer-owned engine before you attach it to a different renderer. A timeline can belong to only one engine at a time.

MethodBehavior
attach(renderer)Installs a frame callback. Only engine can move renderers
detach()Removes the frame callback and drops engine-owned live state
register(timeline)Adds one timeline and watches its state
unregister(timeline)Removes one timeline and its state listener
clear()Unregisters all timelines from this engine
update(deltaTime)Updates every registered timeline that is not synced

attach() checks already-playing timelines and updates the renderer's live state. You do not need an explicit attach call when you use getTimelineEngine(renderer) or createTimeline(options, renderer).

The engine schedules frames while any registered, unsynced timeline is playing and incomplete. The fastest such timeline's frameRate (or engine.defaults.frameRate, 60 by default) sets the rate. At or above the renderer's targetFps, the engine requests live rendering and updates timelines on every frame. Below it, the engine ticks on the shared animation clock instead and updates timelines on each tick. A frameRate of 0, a negative value or NaN throws a RangeError; Infinity always holds the loop. It stops after the last such timeline stops.

createTimeline() never unregisters its result. A completed timeline remains in the engine set until application cleanup unregisters it.

An engine can serve several components in one renderer. Do not use clear() for component cleanup or for one independently owned timeline.

Cleanup

The canonical Core cleanup sequence is:

  1. Call timeline.pause().

  2. Call engine.unregister(timeline).

  3. Call engine.detach() only when this owner explicitly controls the whole engine.

  4. Destroy the renderer through the application's normal lifecycle.

Unregister synced children that were registered separately. A child made with new Timeline() does not need unregistration unless you registered it.

Do not call renderer.requestLive() or renderer.dropLive() for a registered timeline. The engine balances its own live request.

See Lifecycle and cleanup for renderer ownership and shutdown order.

Framework mapping

APISetupCleanup
Core new Timeline()Application registers and plays itApplication pauses and unregisters it
Core createTimeline()Factory registers and normally plays itApplication pauses and unregisters it
React useTimeline()Hook mount effect registers and normally plays itEffect cleanup pauses and unregisters the mount instance
Solid useTimeline()Hook mount handler registers and normally plays itCleanup pauses and unregisters it

React and Solid use the component's renderer-owned engine. Creating or destroying a root for another renderer does not move that engine or unregister its timelines.

Both hooks treat their option input as setup-time data. Later option changes do not update the mounted timeline.

React behavior

The React hook creates its Timeline once through lazy useState. Rerenders return the same registered timeline.

The first render also captures autoplay and all constructor options. Later option values do not reconfigure that timeline.

The React package has a regression test that checks timeline identity across a state-driven rerender.

Solid behavior

The Solid hook creates one timeline during component setup. Its options are not reactive after setup.

Both framework packages test that hook timelines on different renderers stay independent.

Animation callbacks

Use renderer.requestAnimationFrame(callback) and renderer.cancelAnimationFrame(handle) for one-shot animation work. Requests and cancellation belong to that renderer. Renderer destruction cancels its pending requests.

Each pending request holds one live request until its callback runs or you cancel it. requestAnimationFrame() returns -1 and stores nothing when the renderer cannot render, for example after destruction.

The callback receives the elapsed milliseconds since the previous frame, not a timestamp. A request made during a frame runs in the next frame.

Each frame runs animation-frame callbacks first. It then runs callbacks from renderer.setFrameCallback(), which include timeline engines. Both run before Core sends staged property changes to native code and runs the native frame step. Changes that these callbacks make appear in the same frame.

A thrown animation-frame callback error stops that frame. The renderer emits render:error, or logs the error when the event has no listener.

OpenTUI no longer installs or replaces process-global requestAnimationFrame, cancelAnimationFrame, or window properties. Migrate terminal applications to the renderer methods. OpenTUI does not change browser animation APIs.

After each animation-frame callback and each setFrameCallback() callback, the renderer yields to the shared Session scheduler. Between callbacks, that scheduler runs ready work for every Session that shares it. It parks Sessions that wait for output or host replies. It cannot interrupt a synchronous JavaScript callback. Long callbacks and large timelines still delay that work.

Animation ticks

renderer.onAnimationTick(fps, callback, options?) calls callback(deltaTime, ticks, time) fps times a second and returns the unsubscribe function. Use it for an animation slower than the renderer, such as a spinner, instead of live: true or a timer that calls requestRender().

All subscribers share one clock: due times are multiples of 1000 / fps. One timer requests a frame at the earliest due time of the fastest subscribers; slower subscribers wait up to their tolerance past their due time, so they tick on those frames. Every frame, whatever its cause, runs each tick that is due within its tolerance, so animations at different rates share frames. Three animations at 20, 12.5, and 10 fps draw 20 frames per second, not 40.

tolerance (milliseconds, below the interval) is how early a tick may run, and how late a slower subscriber's tick may run. It defaults to half the interval. Every window is at least 1 ms wide, because timers can fire up to 1 ms early. deltaTime is the time since the subscriber's previous tick and time is the frame's clock time, so a time-based animation draws the right value when a tick runs early. ticks counts the due times that the frame covers; it exceeds 1 only after a stall or above maxFps.

Ticks run before animation-frame callbacks. A render request from a tick callback needs no extra frame. The tick timer is not scheduled work, so renderer.idle() resolves between ticks. A live loop runs ticks on its own frames. Renderer destruction removes every subscriber.

A renderable can set animationFps instead. Core then calls its protected onAnimationTick(deltaTime, ticks, time) hook while the node is drawn: a tick after a frame that did not draw it (the node or an ancestor is hidden, a ScrollBox culls it, or it was removed) stops its clock, and its next draw starts it again. Solid's onAnimationTick(fps, callback, options?) and React's useAnimationTick(fps, callback, options?) subscribe for the component's lifetime; an undefined rate pauses them. React commits state updates from the callback synchronously, so they draw in the tick's frame.

Limits and unsupported features

Timeline arrays have no built-in item limit. Bound registered timelines, items, and synced children with application policy.

Each engine update is proportional to registered timelines and their items and children. Nested synced timelines add their own item work.

Completed normal items remain in the scan. Use once() for temporary animations that should remove themselves.

Timeline supports top-level numeric interpolation only. It does not interpolate colors, strings, units, nested paths, or keyframe arrays.

The API has no spring or physics solver. It also has no seek, item removal, unsync, per-item pause, or public reverse method.

Use alternate for per-cycle reversal. Use application state and a new timeline when you need a scheduling change that the API does not support.

Next

Changes0.8.0
0.8.0
core: renderer.onAnimationTick(fps, callback, { tolerance }) runs callbacks on one shared animation clock, so animations at different rates share frames. Renderable.animationFps ticks the protected onAnimationTick() of a node while it is drawn, and a Timeline with a frameRate below targetFps ticks on the clock. (#1803)
Added Timeline.frameRate, TimelineOptions.frameRate.