Skip to content

Motion

js
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

ts
damp(targetValue: number, currentValue: number, factor: number, elapsed: number, precision?: number): number
js
let 
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

js
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

ts
clampDampFactor(factor: number): number

Keeps a factor in the usable range.

decayOver

ts
decayOver(retained: number, elapsed: number): number

The decay of an elapsed time.

Springs

spring

ts
spring(
  targetValue: number,
  currentValue: number,
  currentVelocity: number,
  elapsed: number,
  options?: { stiffness?: number; damping?: number; mass?: number; precision?: number },
): [value: number, velocity: number]
js
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

ts
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.

js
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 subscriber

Several channels on one subscription

js
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 moves

The 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

js
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:

  1. A component's factor is an option, and $options is a live view over attributes — so a factor captured once would freeze an attribute the framework keeps live.
  2. It expresses a factor that depends on the direction of travel: read the current value and decide.
  3. 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

ts
inertiaDecay(dampFactor: number, elapsed: number): number

decayOver with the tighter clamp a coast needs.

inertiaTimeConstant

ts
inertiaTimeConstant(dampFactor: number): number

τ = INERTIA_FRAME / ln(1 / damp).

inertiaStep

ts
inertiaStep(velocity: number, dampFactor: number, elapsed: number): number

The distance travelled across the step.

inertiaFinalValue

ts
inertiaFinalValue(value: number, velocity: number, dampFactor: number): number

Where it will come to rest: value + velocity · τ.

Constants

INERTIA_FRAME

ts
const INERTIA_FRAME: number;

16.67 — the reference frame, in milliseconds.

DEFAULT_DAMP_FACTOR

ts
const DEFAULT_DAMP_FACTOR = 0.85;

MAX_SPRING_RATIO

ts
const MAX_SPRING_RATIO: number;

The clamp on stiffness / mass.

MIT Licensed