@vectorvesper/motionCore Engine · Single rAF Pipeline

FrameConductor

Ten uncoordinated requestAnimationFrame loops fight for a single 16.7ms frame budget and interleave layout reads with DOM writes. FrameConductor gives every effect one shared loop with three ordered phases (input → update → render) so layout computes at most once per frame.

The FrameConductor is accessed via the getConductor() entry point. Most components interact with it through higher-level hooks such as useTick, useSensorBus, and useSceneGate. Reach for getConductor() directly when writing custom canvas engines, WebGL renderers, or physics loops that must coordinate with the rest of the application without spinning uncoordinated loops.

Subscribers execute in three strictly ordered phases each frame: input (passive sensor and layout reads) → update (spring mathematics, damping, and simulation) → render (DOM transform writes, WebGL draws, and canvas paint). Every read finishes before any write begins, ensuring the browser recomputes layout at most once per frame.

The animation loop initializes on the first subscriber and automatically calls cancelAnimationFrame when all subscribers unmount, consuming zero CPU cycles when idle.

One loop, three lanes

input · read update · math render · write DOM
Demo loads on scroll

Quick start

Subscribe in an effect. The callback receives (dt, time) in seconds; return the unsubscribe function directly to useEffect so work stops cleanly on unmount.

The loop starts with this subscriber and stops when it unmounts. There is no idle animation loop burning CPU in the background.

The three lanes

Every frame runs in the exact same phase sequence regardless of subscription order. A render callback always runs after every input and update callback in the same frame.

LaneUse it forAvoid
inputReading pointer, scroll, layout rects, sensors, telemetryDOM or style writes
updateDamping, spring physics, matrix interpolation, predictionDOM reads and writes
renderCSS transforms, canvas 2D draws, WebGL uniformsLayout reads (getBoundingClientRect)

API Reference

Syntax

The subscribe() method registers a callback to run once per animation frame in the designated lane.

Parameters

Parameters.fieldTypeDefaultDescription
lane"input" | "update" | "render"(required)The execution lane for this callback.
fn(dt: number, time: number) => void(required)dt is elapsed seconds since this subscriber last ran. time is the global clock timestamp in seconds.
options.priority"essential" | "enhanced" | "decorative""enhanced"Shed order when a frame runs long. essential is never shed; enhanced sheds once 70% of the budget is spent; decorative sheds at 45%.
options.hznumberevery frameThrottle subscriber to a lower cadence (e.g. 30Hz). dt accumulates across skipped frames so spring physics remains 100% accurate.
options.labelstring"anonymous"Human-readable name displayed in DevTools inspector and slow-subscriber warnings.
options.scopestringundefinedInteraction scope ID this work belongs to. Background work yields earlier when an active foreground scope holds priority.

Return Value

() => void

The unsubscribe function. Removes callback from the subscriber set. When the subscriber count drops to 0, cancelAnimationFrame is called and the engine sleeps.

Properties & State

Properties.fieldTypeDescription
stateConductorStatsReal-time snapshot of the engine: running state, display Hz, fps, frameMs, workMs, worstWorkMs, carriedOverrunMs, preRuntimeMs, activeScope, subscriberCount, shedLastFrame, and subscribers.

ConductorStats

ConductorStats.fieldTypeDescription
runningbooleanWhether the loop is alive. False when 0 subscribers are registered.
displayHznumberDetected display refresh rate (e.g. 60, 120, 144Hz).
frameBudgetMsnumberOne presented frame in ms (16.7ms at 60Hz, 8.3ms at 120Hz).
fpsnumberSmoothed frames per second measured from rAF interval.
frameMsnumberSmoothed interval between presented frames, including third-party work.
workMsnumberSmoothed time this runtime spent executing subscribers.
worstWorkMsnumberDecaying peak of workMs to track worst-case frame spikes.
carriedOverrunMsnumberOverrun debt carried from the previous frame and subtracted from the current budget.
preRuntimeMsnumberTime elapsed before the runtime received the frame tick (e.g. third-party script lag).
activeScopestring | nullForeground interaction scope currently holding priority.
subscriberCountnumberTotal active subscribers across input, update, and render.
shedLastFramenumberSubscribers skipped on the most recent frame due to budget limits.
subscribersSubscriberStat[]Per-subscriber profiling breakdown, sorted by cost.

Global Configuration: configure()

Configures global runtime policy across all subscribers on the page. Call it once at application startup.

ConductorConfig.fieldTypeDefaultDescription
sheddingbooleantrueDrop low-priority work when a frame runs long. Set false only for deterministic video rendering or offline benchmarks.
onError(error, label, lane) => voidconsole.errorInvoked when a subscriber throws. The loop carries on; use this to wire error monitoring like Sentry.
slowSubscriberMsnumber0 (off)Warn once per subscriber that exceeds this run time in ms, naming it by its label.

Unsubscribing & Lifecycle Cleanup

To prevent memory leaks and ensure the frame engine goes to sleep when components unmount, always store or return the unsubscribe function returned by subscribe().

1. Vanilla JS / Canvas SceneManual Cleanup
2. React — use useTickNothing to clean up

Lifecycle & Invariant Guarantees

  • Zero idle cost: The rAF loop starts with the first subscriber and calls cancelAnimationFrame after the last unsubscribe. There is no background loop burning CPU.
  • Fixed lane order: input always finishes before update runs, and update always finishes before render writes to the DOM or GPU. Layout calculates at most once per frame.
  • Delta time is clamped: dt is capped at 0.1s. When a user backgrounds a browser tab for 30 seconds and switches back, it steps forward by 100ms instead of teleporting spring chains.
  • Error isolation: An exception in one callback is caught and reported to onError, preventing one failing effect from halting other subscribers.
  • Nothing starves: A subscriber shed four frames in a row is forced through on the fifth frame regardless of budget. Low-priority work degrades to a lower cadence (such as 15fps) rather than freezing indefinitely.
  • Safe lifecycle edits: A subscriber can safely unsubscribe itself or add another subscriber during a tick without skipping or corrupting the current frame iteration.

Production Examples

Battle-tested production patterns ready to copy directly into your codebase.

The canonical two-lane split: read the world in input, write the DOM in render. Nothing touches React state.

When NOT to use this

A one-off transition such as a hover lift, fade in, or menu toggle.
→ instead CSS transitions or animations running directly on the compositor thread.
Animating React layout state (accordion collapse, enter/exit, route transitions).
→ instead Framer Motion or standard CSS grid/height transitions.
Heavy CPU computation such as thousands of n-body physics bodies or particle math.
→ instead A Web Worker to compute positions off-thread and post Float32Array buffers back.
Waiting on something asynchronous that isn't per-frame (network fetch, element entering view).
→ instead Promises, setTimeout, or native IntersectionObserver.

Rules & Gotchas

  • Keep callbacks small: The conductor is a single main-thread pipeline; expensive work in one callback delays every subscriber queued after it.
  • Direct DOM Mutation over React State: Store changing values in variables or refs and mutate DOM styles or WebGL uniforms directly. Storing 60fps frame values in useState triggers continuous React reconciliation.
  • Read in input, write in render: Reading DOM layout in the render phase or writing DOM in the input phase reintroduces layout thrashing.
  • Pair decorative tracking with hz: 30: Ambient background particles or decorations can run at 30Hz with hz: 30, halving CPU cost while preserving smooth physics.

Related Motion Components