clock (createClock / defaultClock)
The frame loop that drives timelines. Use the shared default clock, or create independent clocks for tests and isolated engines.
A clock calls update(dt) on every subscribed timeline once per animation frame. A timeline subscribes while it is PLAYING and unsubscribes when it pauses, ends, resets or is disposed.
Example
import * as a from "@freestylejs/ani-core";
import { createClock } from "@freestylejs/ani-core";
// Independent clock that you drive yourself (tests, replays, workers).
let now = 0;
const queue: Array<(t: number) => void> = [];
const clock = createClock({
requestFrame: (cb) => { queue.push(cb); return 1; },
cancelFrame: () => { queue.length = 0; },
now: () => now,
});
const timeline = a.rafTimeline(a.ani({ to: { x: 100 }, duration: 1 }), { clock });
timeline.play({ from: { x: 0 } });
now += 500;
queue.shift()?.(now); // one frame, 500 ms later (a gap up to lagThreshold applies in full)
console.log(clock.size); // 1 subscriberUsage & Concepts
The default clock
Timelines use one shared process-wide clock unless you pass another. It runs one requestAnimationFrame loop no matter how many timelines play, and it stops the loop when the last timeline leaves.
import { defaultClock } from "@freestylejs/ani-core";
defaultClock.configure({ lagThreshold: 1 }); // tune the shared clock's time model
defaultClock.size; // number of timelines currently drivendefaultClock is the shared instance. Dispose the timelines you create instead of resetting it. For tests, createManualClock from @freestylejs/ani-core/testing builds a clock you drive by hand.
Guarantees
- One pending frame at most. A re-entrant
subscribe/unsubscribenever creates a second loop. - Three phases per frame. Every tick runs read, then update, then commit over all subscribers, so layout reads and DOM writes never interleave. See Frame phases.
- Per-tick snapshot. A timeline subscribed during a tick (in any phase) first runs on the next tick. One unsubscribed before its update ran gets neither
updatenorcommitin that tick. - Progress since subscribe. The first update of a new subscriber advances by the time elapsed since it subscribed, not by the time since the previous frame.
- Error isolation. A callback that throws, in any phase, does not stop the others or the next frame. The error goes to
onErrorwhen given, otherwise it is rethrown asynchronously. The subscriber stays subscribed unless it unsubscribes itself or fails in 3 consecutive ticks (see Quarantine). - Self-healing.
subscribere-arms the loop when the environment dropped the pending frame (for example a fake rAF reinstalled). A pending frame older than one second is treated as lost. - No frame source, no work. Without
requestAnimationFrame(server rendering, plain workers)subscribeis a silent no-op and the subscriber is not retained. See SSR.
Frame phases
A subscriber implements update and may add read and commit. The rule: read in read, compute in update, write in commit.
import { defaultClock } from "@freestylejs/ani-core";
defaultClock.subscribe({
read() { /* measure layout, sample inputs; no DOM writes */ },
update(dt) { /* advance by dt seconds and compute; no DOM access */ },
commit() { /* write this frame's results to the DOM */ },
});Each frame runs the phases in order over the same snapshot of subscribers:
- Read: first the one-shot callbacks queued with
clock.read(fn)(FIFO), thenread()of every subscriber that has one. - Update:
update(dt)of every subscriber that is still subscribed. - Commit:
commit()of every subscriber whoseupdateran in this tick, even if it unsubscribed during the tick, so a timeline that ends in this frame still writes its final value.
A subscriber with only update keeps working unchanged. clock.read(fn) arms an idle loop and runs fn once, in the next frame before any update; callbacks queued during a tick run in the following tick, and a queued callback does not keep the loop alive afterwards. Without a frame source it is a silent no-op.
Time scale
timeScale (default 1) multiplies every delta handed to subscribers, after lag smoothing and maxDeltaTime, including the first delta of a subscriber that joined a running loop. Use it for slow motion, fast forward or a global freeze.
import { createClock } from "@freestylejs/ani-core";
const clock = createClock({ timeScale: 0.5 }); // half speed
clock.timeScale = 2; // double speed from the next frame
clock.timeScale = 0; // freeze: frames keep running, timelines stay PLAYING with dt = 0
clock.timeScale = -1; // throws RangeError (must be finite and >= 0)A change applies from the next frame and never alters lag detection: a suspension still advances by one ordinary frame, then scaled.
Pause and resume
clock.pause() cancels the pending frame and keeps every subscriber, so nothing runs and no frame is requested. While paused, subscribe and clock.read(fn) retain their work but arm nothing; unsubscribe works as usual.
clock.resume() re-arms the loop (when something is subscribed or queued) with a restarted time base: the paused wall-clock gap is never applied, and the first frame advances by the time since resume(). Both calls are idempotent, resume() on a running clock does nothing, and dispose() while paused leaves clock.paused === false.
Unlike timeScale = 0, a paused clock requests no frames at all.
Quarantine
A subscriber whose read, update or commit throws in 3 consecutive ticks is unsubscribed so a broken animation cannot report forever. Each of the three errors goes to onError as usual, followed by one extra AniError with code ANI_SUBSCRIBER_QUARANTINED whose cause is the last error.
import { AniError, createClock } from "@freestylejs/ani-core";
createClock({
onError(error) {
if (error instanceof AniError && error.code === "ANI_SUBSCRIBER_QUARANTINED") {
console.warn("dropped:", error.cause);
}
},
});A tick in which the subscriber runs without throwing resets its count, and so does unsubscribing. Other subscribers are never affected.
Frame time: lag smoothing
The gap between two frames is turned into animation time with one rule:
| Gap since the previous frame | Treated as | Animation advances by |
|---|---|---|
during which the page was hidden (a visibilitychange event fired while hidden was true), whatever its length | suspension: the tab was hidden | one ordinary frame (the latest gap of at most 50 ms; 1/60 s before any) |
up to lagThreshold (default 0.5 s) | jank: the page is visible but busy | the real elapsed time |
above lagThreshold | suspension: hidden tab, debugger, sleep | one ordinary frame (the latest gap of at most 50 ms; 1/60 s before any) |
- No slow motion under jank. A 1 s animation under 250 ms frames still takes 1 s. RAF timelines stay on the wall clock, in sync with WAAPI, CSS transitions, audio and timers.
- No jump after a suspension. When a hidden tab comes back, animations continue where they paused instead of finishing instantly.
- Hidden means suspended, however short. With the default
visibility: truethe clock listens tovisibilitychangewhile its loop runs, so a tab hidden for 0.3 s (belowlagThreshold) is still a suspension instead of a 0.3 s jump. Detection is event driven: ahiddenvalue merely read at frame time never suspends. An idle clock holds no listener;visibility: falsedisables detection, an injectedVisibilitySourcelets tests drive it, and without a usabledocument(SSR, workers) it is a silent no-op. Invalid values throw aTypeError. - Pure wall clock:
lagThreshold: Infinityapplies every gap in full, exactly like WAAPI. This is safe: dynamic springs integrate in fixed substeps and stop once settled, and wholerepeatiterations are skipped, so even an hour-long gap costs well under a millisecond. - Hard cap:
maxDeltaTime(unlimited by default) caps every delta. A finite value dilates animation time whenever a frame is slower; only set it deliberately. - The first update of a timeline that joined a running loop follows the same rule, measured from its
play(). - Invalid values (zero, negative,
NaN) throw aRangeError. - Non-objects and unknown keys passed to
configureorcreateClockthrowANI_INVALID_OPTIONSsynchronously.
API Reference
createClock(options?)
Returns an independent Clock (AnimationClockInterface plus a read-only size and read(fn)).
| Option | Type | Default | Description |
|---|---|---|---|
lagThreshold | number | 0.5 | Gaps up to this many seconds apply in full; longer gaps count as one ordinary frame. Infinity = pure wall clock. |
maxDeltaTime | number | Infinity | Hard cap on one frame's dt in seconds. A finite value dilates time on slow frames. |
timeScale | number | 1 | Multiplier of every delta. Finite and >= 0; 0 freezes animation time. Invalid values throw a RangeError. |
visibility | boolean | VisibilitySource | true | A frame gap during which the page became hidden is a suspension whatever its length. true uses globalThis.document, an object injects a VisibilitySource, false disables detection (no listener). |
requestFrame | (cb) => number | requestAnimationFrame | Frame source. Resolved lazily at the first subscribe. |
cancelFrame | (id) => void | cancelAnimationFrame | Cancels a requested frame. |
now | () => number | performance.now | Time source in milliseconds, same domain as the frame timestamps. |
reducedMotion | 'system' | boolean | { matches: boolean } | 'system' | The reduced-motion preference timelines read at play(). 'system' follows matchMedia('(prefers-reduced-motion: reduce)') (false without matchMedia); a boolean forces it; an object with a boolean matches (a MediaQueryList) is read live. Anything else throws a TypeError. See Reduced motion. |
onError | (error) => void | async rethrow | Receives errors thrown by subscribers. |
Clock
| Member | Description |
|---|---|
defaultClock | The process-wide shared clock instance. Creating it touches neither the DOM nor requestAnimationFrame. |
clock.configure({ lagThreshold?, maxDeltaTime? }) | Reconfigures the frame timing of any clock, defaultClock included (validated before anything changes). |
clock.size | Number of animatables currently subscribed. |
clock.subscribe(animatable) / clock.unsubscribe(animatable) | Used by timelines. An Animatable is { read?(), update(dt), commit?() }. |
clock.read(fn) | Runs fn once in the read phase of the next frame, before any subscriber's read / update. |
clock.timeScale | Get / set the delta multiplier. Throws RangeError for values that are not finite or are negative. |
clock.reducedMotion | Read-only boolean: the current effective reduced-motion preference. |
clock.paused | true after pause() until resume() or dispose(). |
clock.pause() / clock.resume() | Stop the loop keeping subscribers / restart it without applying the paused gap. Idempotent. |
clock.dispose() | Stops the loop and drops all subscribers and queued read callbacks. |
Related Components
rafTimeline- Subscribes to a clock while playing.Testing- Deterministic tests with a manual frame source.