Core API
follow (a value that chases a target)
A spring-driven value that keeps its velocity when the target changes, so repeated input builds on where the motion is heading.
follow creates a value that springs toward a target. Setting a new target keeps the current velocity, so the motion never stops or kinks at an intermediate input. It is built on rafTimeline with a dynamicSpring timing, and ends once the spring has settled.
Example
Keyboard paging: every press moves the target by one page, relative to where the motion is heading, not where it currently is.
import * as a from "@freestylejs/ani-core";
const list = document.querySelector<HTMLElement>(".list")!;
const scroll = a.follow({ y: 0 });
scroll.bind(list, {
resolver: { y: (v) => ({ key: "transform", value: `translateY(${-v}px)` }) },
});
window.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown") {
// three quick presses end at +300, with no slowdown in between
void scroll.set({ y: scroll.target.y + 100 });
}
});Drag and release: follow the pointer with jump, then fling with the release velocity.
import * as a from "@freestylejs/ani-core";
const element = document.createElement("div");
const card = a.follow({ x: 0 });
card.bind(element, {
resolver: { x: (v) => ({ key: "transform", value: `translateX(${v}px)` }) },
});
let dragging = false;
let startX = 0;
let lastX = 0;
let lastTime = 0;
let pointerVelocity = 0; // px per second
element.addEventListener("pointerdown", (event) => {
dragging = true;
startX = event.clientX;
lastX = event.clientX;
lastTime = event.timeStamp;
});
element.addEventListener("pointermove", (event) => {
if (!dragging) return;
const dt = (event.timeStamp - lastTime) / 1000;
if (dt > 0) pointerVelocity = (event.clientX - lastX) / dt;
lastX = event.clientX;
lastTime = event.timeStamp;
card.jump({ x: event.clientX - startX });
});
element.addEventListener("pointerup", () => {
dragging = false;
// spring home, starting at the speed of the pointer
void card.set({ x: 0 }, { velocity: { x: pointerVelocity } });
});Usage & Concepts
- Keys are fixed by
initial.setandjumptake a partial record and merge it into the current target / value. An unknown key, a non-finite number or a non-record throws aTypeErrorsynchronously and changes nothing. set(partial):targetchanges immediately, then the value springs toward it. The current velocity carries over;options.velocityreplaces it for the given keys.- The promise of
setresolves'ended'when the spring settles,'interrupted'when a laterset/jumpreplaces the motion and'disposed'afterdispose(). Asetto the current target while idle resolves'ended'at once. jump(partial): value and target change at once, velocity is0, status isIDLE. Bound targets are written and listeners notified synchronously; a pendingsetresolves'interrupted'.statusisPLAYINGwhile moving andIDLEbefore any motion and once settled. A moving follower adds one clock subscription, an idle one none.springis{ m, k, c, tolerance? }, validated likea.timing.dynamicSpring(RangeError). The default is{ m: 1, k: 170, c: 26 }.bind(target, [{ resolver }])writes the value in the commit phase (changed declarations only) and writes the current value at once, also before any motion.onUpdatelisteners get one{ state, status }frame per clock frame while moving (commit phase), plus the synchronous notification ofjump.dispose()stops the motion and drops bindings and listeners. Latersetcalls resolve'disposed',jumpdoes nothing. It is idempotent.
Reduced motion
follow takes reducedMotion?: 'jump' | 'ignore' (default 'jump'). The preference is read from the clock when set() is called:
'jump'(default): while reduced motion is reported,set()reaches the target on the next frame with zero velocity, notifies once withIDLE, and resolves'ended'.'ignore': animates normally with the spring.{ maxDuration }is rejected (TypeError, codeANI_INVALID_OPTIONS: a spring has no duration).
See Reduced motion.
API Reference
function follow<G extends Record<string, number>>(
initial: G,
options?: {
spring?: { m: number; k: number; c: number; tolerance?: number }; // { m: 1, k: 170, c: 26 }
clock?: AnimationClockInterface;
reducedMotion?: 'jump' | 'ignore'; // default 'jump'
}
): Follower<G>;
interface Follower<G extends Record<string, number>> {
readonly value: Readonly<G>; // frozen
readonly velocity: Readonly<G>; // units/s, zeros when idle
readonly target: Readonly<G>;
readonly status: "IDLE" | "PLAYING";
set(target: Partial<G>, options?: { velocity?: Partial<G> }): Promise<RunResult>;
jump(value: Partial<G>): void;
bind(
target: { style: CSSStyleDeclaration } | HTMLElement,
options?: { resolver?: Resolver<Record<string, number>> }
): () => void;
onUpdate(
listener: (frame: { state: Readonly<G>; status: "IDLE" | "PLAYING" }) => void
): () => void;
dispose(): void;
}Related Components
rafTimeline- The enginefollowis built on.timing-dynamicSpring, the physics behind the motion.clock- The frame loop the follower subscribes to while moving.