Spring values
A spring value is a number that moves with a spring and remembers its velocity. It is the building block under animate and under the React hooks, and it is the right tool for anything that is not a style: a counter, a progress bar you draw yourself, a canvas coordinate.
Functions
Section titled “Functions”createSpringValue
Section titled “createSpringValue”Creates a SpringValue: a number you can animate toward targets, interrupt, freeze and observe.
export function createSpringValue(initial: number, options: SpringValueOptions = {}): SpringValue| Parameter | Type | Default | Description |
|---|---|---|---|
initial |
number |
none | The starting position. Must be finite. |
options |
SpringValueOptions |
{} |
The scheduler and default rest thresholds. |
Returns a SpringValue at rest at initial, with velocity 0. Creating it requests no frame.
Behavior
- Calling
set(target, options)creates a spring from the current position and velocity and drives it from anupdateloop on the scheduler. One loop job per value, however often you retarget. - The first frame after
set()ist = 0: it reports the start state. Later frames advance by the real time between frames. - Retargeting while it animates builds the new spring from the last computed state, timed from the frame that produced it, so the next frame advances by a real delta and the velocity is continuous.
- The frame that settles sets
positionto the target andvelocityto0, notifies the listeners once, resolves theset()promise withtrueand ends the loop. - There is no
dispose.stop()freezes the value; unsubscribe your listeners yourself. A running spring keeps the value reachable from the scheduler until it settles.
Edge cases
- A non-finite
initialthrowsRangeError("initial value must be a finite number, received NaN"). - Server rendering: creating a value touches nothing. Calling
seton a server uses the default frame source, which falls back to a 16 ms timer withoutrequestAnimationFrame. - Reduced motion is not applied, because a value is just a number. Decide in your own code and call
jumpinstead ofset.
Click, drag or press an arrow key on the track below. Each new target starts from the current position and velocity, and the velocity trace never jumps.
Retarget a spring mid-flight
Click or drag along the track, or focus it and use the arrow keys, Home and End. Retarget while the marker is still moving.
- Position
- 0.0 px
- Velocity
- 0 px/s
Click the track to send the marker somewhere.
Example
import { createSpringValue } from "@damped/core";
const progress = createSpringValue(0, { restDelta: 0.01 });
const stopListening = progress.onChange((value, velocity) => { console.log(value.toFixed(1), velocity.toFixed(1));});
const settled = await progress.set(100, { duration: 0.6, bounce: 0 }); // trueconst interrupted = progress.set(0); // retargets from the current stateprogress.rebase(50, 0); // replace the state; `interrupted` is still pendingprogress.stop(); // `interrupted` resolves false
stopListening();console.log(settled, await interrupted, progress.get());See also: SpringValue, SpringValueOptions, createSpring, useSpringValue, Interruption and reversal, Demos
SpringValueOptions
Section titled “SpringValueOptions”The options of createSpringValue: which scheduler drives the value and the default rest thresholds.
export interface SpringValueOptions { /** Scheduler that drives the value. Defaults to the shared `frame` scheduler. */ scheduler?: Scheduler; /** Default rest thresholds forwarded to every spring this value creates; per-call options win. */ restDelta?: number; restSpeed?: number;}| Field | Type | Default | Description |
|---|---|---|---|
scheduler |
Scheduler |
the shared frame |
Drives the value with one update loop per animation. |
restDelta |
number |
none; the spring default 0.001 applies |
Forwarded to every set. A per-call restDelta overrides it. |
restSpeed |
number |
none; the spring default 0.01 applies |
Forwarded to every set. A per-call restSpeed overrides it. |
Behavior
- The thresholds are not validated at construction. An invalid value throws a
RangeErrorfrom the nextset(). - The thresholds are in the units of the value. For a value that spans thousands, a larger
restDeltaends the animation sooner at no visible cost; for a value that spans 0 to 1 the defaults are already proportionate.
Example
import { createScheduler, createSpringValue, type SpringValueOptions } from "@damped/core";
// A test or a game loop supplies its own scheduler; the value then moves only when that scheduler's frames run.const options: SpringValueOptions = { scheduler: createScheduler(), restDelta: 0.5, restSpeed: 5 };const counter = createSpringValue(0, options);
void counter.set(1000, { duration: 0.8, bounce: 0 }); // settles within half a unit of 1000See also: createSpringValue, Scheduler, SpringOptions, Testing
SpringValue
Section titled “SpringValue”A number driven by a spring: read it, move it, interrupt it and observe it.
export interface SpringValue { get(): number; getVelocity(): number; readonly animating: boolean; set(target: number, options?: SpringOptions): Promise<boolean>; jump(value: number): void; rebase(position: number, velocity?: number): void; stop(): void; onChange(listener: (value: number, velocity: number) => void): () => void;}| Member | Type | Description |
|---|---|---|
get() |
() => number |
The last computed position. |
getVelocity() |
() => number |
The last computed velocity in units per second. 0 at rest. |
animating |
boolean |
true from set() until it settles or is superseded by jump or stop. |
set(target, options?) |
(target: number, options?: SpringOptions) => Promise<boolean> |
Animate to target. Resolves true once settled there, false if a later set, jump or stop superseded it. |
jump(value) |
(value: number) => void |
Set immediately with zero velocity, cancelling any animation. |
rebase(position, velocity = 0) |
(position: number, velocity?: number) => void |
Replace the current state without ending the animation. |
stop() |
() => void |
Freeze at the current value with zero velocity. |
onChange(listener) |
(listener: (value: number, velocity: number) => void) => () => void |
Subscribe to changes. Returns an unsubscribe function. |
Behavior
setthrows aRangeErrorsynchronously, not as a rejected promise, for a non-finitetargetor invalid spring options, and leaves the value untouched. When the value is idle at the target with zero velocity it resolvestrueat once and requests no frame. An earlier pendingsetresolvesfalse; the same target while animating still retargets and supersedes.jumpthrows for a non-finite value, cancels any animation (its promise resolvesfalse), sets the value with0velocity and notifies the listeners, also when idle and without requesting a frame.rebasethrows for a non-finite argument and leaves the state untouched. While animating it rebuilds the spring toward the same target with the same options from the new state, notifies, and neither resolves nor supersedes the pendingset. While idle it behaves likejump(position)without cancelling anything: the velocity is ignored and no frame is requested.stopresolves the pendingsetwithfalse, zeroes the velocity and does not notify the listeners. On an idle value it does nothing.onChangeis called with(value, velocity)on every change (frames,jump,rebase), in subscription order. The unsubscribe function is idempotent. Subscribing the same function twice makes two subscriptions. The list is snapshotted, so a listener may unsubscribe itself or others while being notified. A listener that throws neither breaks the value nor starves the others; the error is rethrown from a microtask.animatingis alreadyfalsewhen listeners run on the settling frame, so a listener may callset()to start a fresh animation.- A value can be animated again after
stop()orjump().
Edge cases
- A stopped value away from its target animates on the next
set(). - Unmounting is your job. In React use
useSpringValue, which stops the value for you.
Example
import { createSpringValue } from "@damped/core";
const volume = createSpringValue(0.2);
// Mirror the value to the screen without a framework.const label = document.querySelector<HTMLElement>("#volume");const unsubscribe = volume.onChange((value) => { if (label !== null) label.textContent = `${Math.round(value * 100)}%`;});
async function fade(target: number): Promise<void> { const settled = await volume.set(target, { duration: 0.4, bounce: 0 }); if (!settled) return; // a newer fade took over console.log("settled at", volume.get());}
void fade(1);setTimeout(() => { console.log("velocity when interrupted:", volume.getVelocity().toFixed(2)); void fade(0); // continues from the current position and velocity}, 150);setTimeout(unsubscribe, 3000);See also: createSpringValue, SpringValueOptions, SpringOptions, useSpringValue, Interruption and reversal