| name | anim-engine |
| description | Renderer-agnostic numeric animation library for JavaScript runtimes. Use when a user has installed 'anim-engine' and needs help writing animation code with tweens, keyframes, timelines, springs, smooth damp, lerp, inertia, smooth scroll, or color interpolation. Covers all exported primitives, common recipes, game engine integration, and the ticker setup pattern. |
| metadata | {"triggers":"anim-engine, animation, createAnimation, createTimeline, createSpring, createSmoothDamp, createLerp, createSmoothClamp, createInertia, createSmoothScroll, inertia, smooth scroll, momentum scroll, lerpRgba, hexToRgba, animation library, js animation, tween, keyframe animation, timeline animation, spring physics"} |
anim-engine
A renderer-agnostic animation library for JavaScript runtimes. Pure-numeric, tree-shakeable, zero dependencies. Works with DOM, Canvas, WebGL, Three.js, PixiJS, or any other renderer.
npm install anim-engine
Getting Started
Import the primitives you need and start a ticker once per application. The ticker drives all animations — it does not auto-start.
⚠️ Call getTicker().start() once at your app's entry point, not inside a component or per animation.
import { createAnimation, getTicker } from "anim-engine";
getTicker().start();
const anim = createAnimation({
from: 0,
to: 100,
durationMs: 1000,
ease: "outCubic",
onUpdate: (value) => (element.style.transform = `translateX(${value}px)`),
});
await anim.play();
Ticker Setup
The ticker must be started before any animations will advance. There are two modes:
⚠️ Call getTicker().start() exactly once per application — at your app's entry point or initialization, not inside a component, hook, or per-animation scope. Multiple calls are harmless (the ticker is a singleton), but instantiating separate ticker loops in components will fight each other, leak resources, and break frame timing.
Automatic (rAF)
import { getTicker } from "anim-engine";
getTicker().start();
Custom game loop for all animations
Pass deltaMs (milliseconds) each frame:
const ticker = getTicker();
function loop(dt: number) {
ticker.update(dt);
}
External ticker (game engine integration)
Pass an ExternalTicker to individual animations to use the engine's own tick:
type ExternalTicker = {
add: (handler: TickHandler) => void;
remove: (handler: TickHandler) => void;
};
Three.js:
const clock = new THREE.Clock();
const ticker = getTicker();
function animate() {
requestAnimationFrame(animate);
ticker.update(clock.getDelta() * 1000);
renderer.render(scene, camera);
}
animate();
PixiJS:
const ticker = getTicker();
app.ticker.add((delta) => ticker.update(delta.deltaMS));
Primitives
Two animation families:
| Family | Factories | What it does |
|---|
| Timed | createAnimation, createTimeline | Fixed motion path. Runs once per play(). |
| Continuous | createSpring, createSmoothDamp, createLerp, createInertia, createSmoothScroll | Chases a live target every frame until stopped. |
| Pure function | createSmoothClamp | Asymptotic saturation — returns (input: number) => number. |
Timed: createAnimation
Single tween
Animates from one value to another over a duration with easing.
const anim = createAnimation({
from: 0,
to: 100,
durationMs: 1000,
ease: "outCubic",
onUpdate: (value, velocity) => {
},
onProgress: (progress) => {},
onStarted: () => {},
onEnded: () => {},
});
await anim.play();
Keyframes
Multiple segments, each with its own value, duration (gap), and easing.
createAnimation({
keyframes: [{ value: 0 }, { value: 50, gap: 300, ease: "outCubic" }, { value: 100, gap: 400 }],
onUpdate: (v) => (sprite.x = v),
}).play();
The first keyframe provides the starting value; gap is the duration from the previous keyframe.
Control handle
anim.play() → Promise<void>
anim.pause() → void
anim.resume() → void
anim.stop() → void
anim.skipToEnd() → void
anim.setProgress(p) → void
anim.value → number
anim.velocity → number
anim.progress → number
anim.status → "playing" | "paused" | "stopped"
anim.durationMs → number
Repeat & yoyo
Since DynamicValue is re-evaluated each play() call, alternating the source toggles direction:
let forward = true;
for (let i = 0; i < 6; i++) {
await createAnimation({
from: () => (forward ? 1 : 1.3),
to: () => (forward ? 1.3 : 1),
durationMs: 600,
ease: "inOutSine",
onUpdate: (v) => sprite.scale.set(v),
}).play();
forward = !forward;
}
Timed: createTimeline
Orchestrate multiple keyframe animations in parallel or sequence.
import { createTimeline } from "anim-engine";
const timeline = createTimeline(
[
{
at: 0,
animation: {
keyframes: [{ value: 0 }, { value: 1, gap: 500 }],
onEnded: () => console.log("layer 1 done"),
},
},
{
at: 0,
animation: {
keyframes: [{ value: -100 }, { value: 0, gap: 800, ease: "outBack" }],
onUpdate: (v) => (sprite.y = v),
},
},
{
gap: 200,
animation: {
keyframes: [{ value: 0 }, { value: 50, gap: 300 }],
onEnded: () => console.log("layer 3 done"),
},
},
],
{
onUpdate: (values, velocities) => {
sprite.x = values[0];
sprite.y = values[1];
sprite.rotation = values[2];
},
onProgress: (p) => console.log(p),
},
);
- Layer positions:
at: DynamicValue (absolute ms) or gap: number (relative to previous layer end).
- Each layer supports its own callbacks. Pass
onStarted, onUpdate, onProgress, and onEnded inside the layer's animation config to react to per-layer events independently.
- Timeline handle has the same shape as
Animation: play(), pause(), resume(), stop(), skipToEnd(), setProgress().
values and velocities are arrays — one entry per layer in definition order.
Continuous: createSpring
Mass-spring-damper physics. Bouncy or elastic movement toward a live target.
const spring = createSpring({
to: () => mouseX,
stiffness: 180,
damping: 12,
mass: 1,
precision: 0.01,
onUpdate: (value, velocity) => {
sprite.x = value;
},
onEnded: () => {},
});
spring.stop();
spring.setValue(50);
Uses Verlet integration for stable, energy-conserving simulation.
Starts active automatically. Call stop() to halt.
Continuous: createSmoothDamp
Natural deceleration toward a target (Unity-style SmoothDamp).
import { createSmoothDamp } from "anim-engine";
const damp = createSmoothDamp({
to: () => targetY,
smoothTimeMs: 300,
maxSpeed: undefined,
precision: 0.01,
onUpdate: (value, vel) => {
},
onEnded: () => {},
});
Continuous: createLerp
Exponential approach. Always moves toward target, never overshoots.
const lerp = createLerp({
to: () => targetX,
smoothTimeMs: 300,
precision: 0.01,
onUpdate: (value, vel) => {
},
onEnded: () => {},
});
Gesture-driven: createInertia
Exponential velocity decay for drag-to-throw gestures. Call track(position) every frame while dragging — it records position and computes velocity from the frame-to-frame delta. On release(), velocity decays exponentially and the value coasts to a stop.
import { createInertia } from "anim-engine";
const inertia = createInertia({
decelerationMs: 300,
precision: 0.5,
onUpdate: (value, velocity) => {
block.style.left = `${value}px`;
},
onEnded: () => {},
});
inertia.track(e.clientX);
inertia.release();
inertia.setValue(50);
Self-registers on the ticker while active (tracking or coasting), removes itself when settled — no explicit lifecycle management needed.
Gesture-driven: createSmoothScroll
Delta-based momentum scroll built on top of smooth damp. Manages an internal target clamped on write so there's no dead zone when coming back from overscroll. Add deltas via addDelta() and the value smooth-damps toward the cumulative target every frame.
import { createSmoothScroll } from "anim-engine";
const scroll = createSmoothScroll({
smoothTimeMs: 30,
min: 0,
max: 650,
precision: 0.5,
onUpdate: (value) => {
viewport.style.transform = `translateX(${-value}px)`;
},
onEnded: () => {},
});
scroll.setValue(scroll.value);
scroll.addDelta(e.deltaY);
scroll.addDelta(e.clientX - lastX);
Self-registers on the ticker only while animating toward the target — automatically stops when settled.
Pure function: createSmoothClamp
Asymptotic saturation — caps a value with smooth deceleration. No control handle, no ticker.
import { createSmoothClamp } from "anim-engine";
const clamp = createSmoothClamp(45);
clamp(1000);
clamp(5);
Useful for limiting velocity, torque, force, or any value that should approach a maximum smoothly.
Color
Perceptually uniform Oklab color interpolation.
import { lerpRgba, hexToRgba } from "anim-engine";
const from = hexToRgba("#ff6b6b");
const to = hexToRgba("#4ecdc4");
createAnimation({
from: 0,
to: 1,
durationMs: 2000,
onUpdate: (t) => {
const [r, g, b, a] = lerpRgba(from, to, t);
element.style.color = `rgba(${r * 255}, ${g * 255}, ${b * 255}, ${a})`;
},
});
Accepted hex formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA.
Dynamic Values
DynamicValue (number | () => number) lets values be static or computed lazily.
Resolution timing depends on the primitive:
| Primitive | from / to / gap / smoothTimeMs resolved |
|---|
createAnimation (tween) | Once per play() call, cached for duration |
createAnimation (keyframes) | Once per play() call |
createTimeline | Once per play() call |
createSpring / createSmoothDamp / createLerp | Every frame (target is a live value) |
createInertia | decelerationMs resolved every frame while coasting |
createSmoothScroll | smoothTimeMs, min, max resolved every frame |
createAnimation({ from: 0, to: 100, durationMs: 1000 });
createAnimation({
from: () => getCurrentX(),
durationMs: () => 500 / speedMultiplier,
});
createSpring({ to: () => mouseX });
Easing
31 named presets plus custom cubic bezier.
type EaseName =
| "linear"
| "inQuad"
| "outQuad"
| "inOutQuad"
| "inCubic"
| "outCubic"
| "inOutCubic"
| "inQuart"
| "outQuart"
| "inOutQuart"
| "inQuint"
| "outQuint"
| "inOutQuint"
| "inSine"
| "outSine"
| "inOutSine"
| "inExpo"
| "outExpo"
| "inOutExpo"
| "inCirc"
| "outCirc"
| "inOutCirc"
| "inBack"
| "outBack"
| "inOutBack"
| "inElastic"
| "outElastic"
| "inOutElastic"
| "inBounce"
| "outBounce"
| "inOutBounce";
Custom bezier:
import { cubicBezier } from "anim-engine";
const ease = cubicBezier(0.25, 0.1, 0.25, 1);
createAnimation({ from: 0, to: 100, durationMs: 1000, ease });
Custom function:
createAnimation({
from: 0,
to: 100,
durationMs: 1000,
ease: (t) => t * t,
});
Common Recipes
Animating CSS transforms
const anim = createAnimation({
from: 0,
to: 100,
durationMs: 2000,
ease: "outElastic",
onUpdate: (v) => {
element.style.transform = `translateX(${v}px) rotate(${v * 0.1}deg)`;
},
});
Animating canvas drawing
const ctx = canvas.getContext("2d");
let progress = 0;
createAnimation({
keyframes: [{ value: 0 }, { value: 1, gap: 1000, ease: "inOutSine" }],
onUpdate: (v) => {
progress = v;
},
}).play();
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.beginPath();
ctx.arc(100 + progress * 200, 100, 30, 0, Math.PI * 2);
ctx.fill();
requestAnimationFrame(draw);
}
draw();
Spring-loaded UI element
import { createSpring } from "anim-engine";
createSpring({
to: () => mouseX,
stiffness: 160,
damping: 14,
onUpdate: (x) => (cursor.style.left = `${x}px`),
});
Fade in with smooth damp
createSmoothDamp({
to: () => (visible ? 1 : 0),
smoothTimeMs: 400,
onUpdate: (v) => (element.style.opacity = String(v)),
});
Polling values in a game loop
Read-only value and velocity properties let you poll instead of using callbacks:
const spring = createSpring({ to: () => enemy.targetX });
function gameLoop(dt: number) {
ticker.update(dt);
enemy.x = spring.value;
enemy.vx = spring.velocity;
}
Drag-to-throw with inertia
import { createInertia } from "anim-engine";
const inertia = createInertia({
decelerationMs: 400,
onUpdate: (v) => (card.style.transform = `translateX(${v}px)`),
});
card.addEventListener("pointerdown", (e) => {
inertia.track(e.clientX);
card.setPointerCapture(e.pointerId);
});
card.addEventListener("pointermove", (e) => {
if (e.buttons) inertia.track(e.clientX);
});
card.addEventListener("pointerup", () => inertia.release());
card.addEventListener("pointercancel", () => inertia.release());
Momentum scroll with smooth scroll
import { createSmoothScroll } from "anim-engine";
const scroll = createSmoothScroll({
smoothTimeMs: 30,
min: 0,
max: 1200,
onUpdate: (v) => (track.style.transform = `translateX(${-v}px)`),
});
viewport.addEventListener("wheel", (e) => {
e.preventDefault();
scroll.addDelta(e.deltaX || e.deltaY);
}, { passive: false });
let lastX = 0;
viewport.addEventListener("pointerdown", (e) => {
lastX = e.clientX;
scroll.setValue(scroll.value);
viewport.setPointerCapture(e.pointerId);
});
viewport.addEventListener("pointermove", (e) => {
if (e.buttons) {
scroll.addDelta(-(e.clientX - lastX));
lastX = e.clientX;
}
});