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.
| API | Initial state | Registration |
|---|---|---|
new Timeline(options?) |
Always paused, including autoplay: true |
Not registered |
createTimeline(options?, renderer?) |
Plays unless autoplay is false |
Uses the renderer’s engine, or engine when omitted |
React or Solid useTimeline(options?) |
Plays on mount unless autoplay is false |
Registers 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 field |
Default | Behavior |
|---|---|---|
duration |
1000 |
Timeline cutoff in milliseconds |
loop |
false |
Restarts the timeline at its cutoff only when exactly true |
autoplay |
true |
Used by the factory and framework mount handlers |
onComplete |
None | Runs when a non-looping timeline reaches its cutoff |
onPause |
None | Runs 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#
| Field | Type | Behavior |
|---|---|---|
duration |
number |
Item duration in milliseconds. Required by the TypeScript interface |
ease |
EasingFunctions |
Named easing function. Default is linear |
onUpdate |
(animation: JSAnimation) => void |
Runs for each active update |
onComplete |
() => void |
Runs after the final item cycle |
onStart |
() => void |
Runs when the item first becomes active |
onLoop |
() => void |
Runs when an intermediate item cycle changes |
loop |
boolean | number |
Infinite cycles for true, or a numeric cycle count |
loopDelay |
number |
Delay after each item cycle. Default is 0 |
alternate |
boolean |
Reverses every odd item cycle. Default is false |
once |
boolean |
Removes the item after completion. Default is false |
| Other numeric keys | number |
End 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:
| Field | Meaning |
|---|---|
targets |
The normalized target array after property mutation |
deltaTime |
The update delta supplied for this evaluation |
progress |
Eased forward progress before alternate reverses assignment |
currentTime |
Current 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:
linearinQuadoutQuadinOutQuadinExpooutExpoinOutSineoutBounceoutElasticinBounceinCircoutCircinOutCircinBackoutBackinOutBack
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#
| Method | Behavior |
|---|---|
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:
| Field | Meaning |
|---|---|
items |
Animation and callback items |
subTimelines |
Synced-child items |
currentTime |
Current parent time in milliseconds |
isPlaying |
Whether parent items advance |
isComplete |
Whether a non-looping parent reached its cutoff |
duration |
Parent cutoff |
loop |
Parent loop flag |
synced |
Whether 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.
| Method | Behavior |
|---|---|
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 requests live rendering when any registered, unsynced timeline is playing and incomplete. It drops live rendering 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.
engine.defaults.frameRate currently exists, but the engine does not read it. Renderer frame scheduling controls update cadence.
Cleanup#
The canonical Core cleanup sequence is:
- Call
timeline.pause(). - Call
engine.unregister(timeline). - Call
engine.detach()only when this owner explicitly controls the whole engine. - 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#
| API | Setup | Cleanup |
|---|---|---|
Core new Timeline() |
Application registers and plays it | Application pauses and unregisters it |
Core createTimeline() |
Factory registers and normally plays it | Application pauses and unregisters it |
React useTimeline() |
Hook mount effect registers and normally plays it | Effect cleanup pauses and unregisters the mount instance |
Solid useTimeline() |
Hook mount handler registers and normally plays it | Cleanup 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.
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.