Motion
import { damp, smoothTo, spring } from '@studiometa/js-toolkit/utils';Everything here takes a time, not a frame count.
Decay is expressed in time, not in frames.
INERTIA_FRAME(16.67 ms) is the reference of every factor.
That is what makes a factor mean the same thing at 60 Hz and at 120 Hz, and it is why damp() takes the elapsed time as a required argument.
Damping
damp
damp(targetValue: number, currentValue: number, factor: number, elapsed: number, precision?: number): numberlet current = 0;
let target = 100;
useRaf().subscribe(({ delta }) => {
current = damp(target, current, 0.1, delta);
});factor is the fraction of the gap that closes per reference frame, so it is stable for every value a caller can pass. precision defaults to 0.01: below it, the value snaps to the target.
v3's damp() had no elapsed
damp(current, target, 0.1);
damp(target, current, 0.1, delta); A factor without a time is a factor that means something different on every display.
clampDampFactor
clampDampFactor(factor: number): numberKeeps a factor in the usable range.
decayOver
decayOver(retained: number, elapsed: number): numberThe decay of an elapsed time.
Springs
spring
spring(
targetValue: number,
currentValue: number,
currentVelocity: number,
elapsed: number,
options?: { stiffness?: number; damping?: number; mass?: number; precision?: number },
): [value: number, velocity: number]let value = 0;
let velocity = 0;
useRaf().subscribe(({ delta }) => {
[value, velocity] = spring(100, value, velocity, delta, { stiffness: 0.1, damping: 0.8 });
});It returns the pair, because a spring's state is the value and its velocity — hiding the velocity would make the next step wrong.
It integrates on a fixed step of a quarter frame, however long the real frame is, so stiffness, damping and mass keep their meaning and the duration is real. stiffness / mass is clamped to MAX_SPRING_RATIO, from which that step is derived.
precision defaults to 1e-4.
Smoothing
smoothTo
smoothTo(start?: number, options?: SmoothToOptions): SmoothTo
smoothTo<K extends string>(start: Record<K, number>, options?: SmoothToOptions): SmoothToRecord<K>A toggle() over useRaf(): one subscription however many times the target is set, started when the value has somewhere to go, released when it arrives.
import { smoothTo } from '@studiometa/js-toolkit/utils';
const x = smoothTo(0, { damping: 0.85 });
x(400); // set a target, read the smoothed value
x(); // read it
x.raw(); // the target, unsmoothed
x.add(50); // move the target
x.jump(0); // set value and target at once — no travel, no frame
x.isMoving; // still travelling?
const unsubscribe = x.subscribe((value) => {
document.body.style.setProperty('--x', `${value}px`);
});
x.destroy(); // release the frame subscription and every subscriberSeveral channels on one subscription
import { smoothTo } from '@studiometa/js-toolkit/utils';
const view = smoothTo({ x: 0, y: 0, scale: 1 });
view({ x: 100, y: 50 }); // only these two are re-aimed; `scale` keeps travelling
view.jump({ scale: 1 }); // reset one channel with no travel
view.isMoving; // true while *any* channel movesThe keys are the consumer's own — a scale, an opacity or a progress as readily as a coordinate. One frame subscription, one settled state (the loop stops when the last channel arrives), and one subscriber call per frame carrying the whole record.
The record handed out is the same object every frame
As a service hands the same props. Treat it as read-only, and copy it with { ...values } to keep one.
The mode — spring, stiffness, mass — belongs to the instance, not the channel.
damping accepts a function
smoothTo({ x: 0, y: 0 }, { damping: (key) => (key === 'x' ? 0.9 : 0.7) });It is read on every frame and for every channel, which matters for three reasons:
- A component's factor is an option, and
$optionsis a live view over attributes — so a factor captured once would freeze an attribute the framework keeps live. - It expresses a factor that depends on the direction of travel: read the current value and decide.
- It gives one channel of a record a different rate from its neighbour.
A number stays a number.
precision
Defaults to the default of the function each mode wraps — 0.01 damping, 1e-4 springing — so converting a raw damp() call to the helper does not move where it snaps.
Inertia
The family a coast is built from, and what useDrag() uses.
inertiaStep() integrates the decay across the step, so any sequence of frames sums to velocity · τ exactly — a coast lands in the same place whatever the frame rate did on the way. inertiaFinalValue() is what lets a carousel know which slide a fling is heading for before the coast starts.
inertiaDecay
inertiaDecay(dampFactor: number, elapsed: number): numberdecayOver with the tighter clamp a coast needs.
inertiaTimeConstant
inertiaTimeConstant(dampFactor: number): numberτ = INERTIA_FRAME / ln(1 / damp).
inertiaStep
inertiaStep(velocity: number, dampFactor: number, elapsed: number): numberThe distance travelled across the step.
inertiaFinalValue
inertiaFinalValue(value: number, velocity: number, dampFactor: number): numberWhere it will come to rest: value + velocity · τ.
Constants
INERTIA_FRAME
const INERTIA_FRAME: number;16.67 — the reference frame, in milliseconds.
DEFAULT_DAMP_FACTOR
const DEFAULT_DAMP_FACTOR = 0.85;MAX_SPRING_RATIO
const MAX_SPRING_RATIO: number;The clamp on stiffness / mass.