Ani
Core API

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 subscriber

Usage & 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 driven

defaultClock 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 / unsubscribe never 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 update nor commit in 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 onError when given, otherwise it is rethrown asynchronously. The subscriber stays subscribed unless it unsubscribes itself or fails in 3 consecutive ticks (see Quarantine).
  • Self-healing. subscribe re-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) subscribe is 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:

  1. Read: first the one-shot callbacks queued with clock.read(fn) (FIFO), then read() of every subscriber that has one.
  2. Update: update(dt) of every subscriber that is still subscribed.
  3. Commit: commit() of every subscriber whose update ran 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 frameTreated asAnimation advances by
during which the page was hidden (a visibilitychange event fired while hidden was true), whatever its lengthsuspension: the tab was hiddenone 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 busythe real elapsed time
above lagThresholdsuspension: hidden tab, debugger, sleepone 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: true the clock listens to visibilitychange while its loop runs, so a tab hidden for 0.3 s (below lagThreshold) is still a suspension instead of a 0.3 s jump. Detection is event driven: a hidden value merely read at frame time never suspends. An idle clock holds no listener; visibility: false disables detection, an injected VisibilitySource lets tests drive it, and without a usable document (SSR, workers) it is a silent no-op. Invalid values throw a TypeError.
  • Pure wall clock: lagThreshold: Infinity applies every gap in full, exactly like WAAPI. This is safe: dynamic springs integrate in fixed substeps and stop once settled, and whole repeat iterations 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 a RangeError.
  • Non-objects and unknown keys passed to configure or createClock throw ANI_INVALID_OPTIONS synchronously.

API Reference

createClock(options?)

Returns an independent Clock (AnimationClockInterface plus a read-only size and read(fn)).

OptionTypeDefaultDescription
lagThresholdnumber0.5Gaps up to this many seconds apply in full; longer gaps count as one ordinary frame. Infinity = pure wall clock.
maxDeltaTimenumberInfinityHard cap on one frame's dt in seconds. A finite value dilates time on slow frames.
timeScalenumber1Multiplier of every delta. Finite and >= 0; 0 freezes animation time. Invalid values throw a RangeError.
visibilityboolean | VisibilitySourcetrueA 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) => numberrequestAnimationFrameFrame source. Resolved lazily at the first subscribe.
cancelFrame(id) => voidcancelAnimationFrameCancels a requested frame.
now() => numberperformance.nowTime 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) => voidasync rethrowReceives errors thrown by subscribers.

Clock

MemberDescription
defaultClockThe 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.sizeNumber 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.timeScaleGet / set the delta multiplier. Throws RangeError for values that are not finite or are negative.
clock.reducedMotionRead-only boolean: the current effective reduced-motion preference.
clock.pausedtrue 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.
  • rafTimeline - Subscribes to a clock while playing.
  • Testing - Deterministic tests with a manual frame source.