anim-engine
v0.6.0
Published
JavaScript library for animating numbers
Readme
Renderer-agnostic animation for JavaScript runtimes. Pure-numeric, tree-shakeable, zero dependencies.
npm install anim-engineimport { createAnimation, getTicker } from "anim-engine";
getTicker().start();
const anim = createAnimation({
from: 0,
to: 100,
durationMs: 1000,
ease: "outCubic",
onUpdate: (value) => (sprite.x = value),
});
await anim.play();AI agents: this package includes a
SKILL.mdwith in-depth usage guidance for agent-assisted development.
Design
Anim Engine is built around the same concepts used in animation applications, applied to pure numbers.
Timed motion — a fixed path evaluated when play() is called.
- Tween — animate from value A to value B over a duration with easing.
- Keyframes — multi-segment motion describing a value at specific points in time.
- Timeline — orchestrate multiple keyframe animations in parallel, sequence, or staggered offset.
Continuous motion — chases a live target every frame.
- Spring — mass-spring-damper physics for bouncy or elastic movement.
- Smooth damp — natural deceleration toward a target (Unity-style).
- Lerp — exponential approach for smooth asymptotic tracking.
- Inertia — velocity decay for drag-to-throw gestures (fling, spin).
- Smooth scroll — delta-based momentum scroll with clamped bounds.
- Smooth clamp — asymptotic saturation for capping velocity, torque, or force.
Color helpers — Oklab-based composable interpolation.
lerpRgba— interpolates between two RGBA colors in perceptually uniform space.hexToRgba— parse hex color strings (#RGB,#RRGGBB, etc.) to normalized RGBA.
Deliberately decoupled
Anim Engine works strictly with numbers to stay renderer-agnostic. Each function accepts an onUpdate callback with the latest value and velocity — assign it to a DOM element, WebGL object, or any other target. Read-only value and velocity properties are also exposed for polling inside a game loop.
Primitives
Two families with complementary shapes:
| Family | Functions | Lifespan |
| -------------- | --------------------------------------------------------------------------------------- | ----------------------------------------- |
| Timed | createAnimation, createTimeline | Fixed motion path, runs once per play() |
| Continuous | createSpring, createSmoothDamp, createLerp, createInertia, createSmoothScroll | Chases a live target until stopped |
All return a control handle with value, velocity, status, and lifecycle methods.
createSmoothClamp is a pure function factory (no handle — returns (input: number) => number).
createInertia and createSmoothScroll are gesture-driven continuous primitives with distinct control handles (see below).
At a glance
Timed — Animation
type Animation = {
play: () => Promise<void>;
pause: () => void;
resume: () => void;
stop: () => void;
skipToEnd: () => void;
setProgress: (value: number) => void;
value: number;
velocity: number;
progress: number; // 0 → 1
status: "playing" | "paused" | "stopped";
durationMs: number;
};Options — single tween:
type TweenOptions = {
from: DynamicValue;
to: DynamicValue;
durationMs: DynamicValue;
ease?: EaseName | EaseFunction;
onStarted?: () => void;
onUpdate?: (value: number, velocity: number) => void;
onProgress?: (progress: number) => void;
onEnded?: () => void;
ticker?: ExternalTicker;
};Options — keyframes:
type KeyframeOptions = {
keyframes: Keyframe[];
onStarted?: () => void;
onUpdate?: (value: number, velocity: number) => void;
onProgress?: (progress: number) => void;
onEnded?: () => void;
ticker?: ExternalTicker;
};
type Keyframe = {
value: DynamicValue;
gap?: DynamicValue; // ms from previous keyframe
ease?: EaseName | EaseFunction;
};Timed — Timeline
Layers multiple keyframe animations in parallel or sequence.
type Timeline = {
play: () => Promise<void>;
pause: () => void;
resume: () => void;
stop: () => void;
skipToEnd: () => void;
setProgress: (value: number) => void;
values: number[]; // one per layer
velocities: number[]; // one per layer
progress: number;
status: "playing" | "paused" | "stopped";
durationMs: number;
};type TimelineLayer =
| { animation: KeyframeOptions; at: DynamicValue } // absolute position
| { animation: KeyframeOptions; gap: number }; // relative to previous layer endContinuous — Interpolation
type Interpolation = {
resume: () => void;
stop: () => void;
setValue: (value: number) => void;
value: number;
velocity: number;
status: "active" | "inactive";
};Options:
type SpringOptions = {
to: () => number
stiffness?: DynamicValue // default 180
damping?: DynamicValue // default 12
mass?: DynamicValue // default 1
precision?: number // default 0.01
onUpdate?: (value: number, velocity: number) => void
onEnded?: () => void
ticker?: ExternalTicker
}
type SmoothDampOptions = {
to: () => number
smoothTimeMs: DynamicValue
maxSpeed?: DynamicValue
precision?: number // default 0.01
onUpdate?: (value: number, velocity: number) => void
onEnded?: () => void
ticker?: ExternalTicker
}
type LerpOptions = {
to: () => number
smoothTimeMs: DynamicValue
precision?: number // default 0.01
onUpdate?: (value: number, velocity: number) => void
onEnded?: () => void
ticker?: ExternalTicker
}
```
### Common option patterns
| | `from` | `to` / target | `onUpdate` values | `durationMs` / `smoothTimeMs` |
|---|---|---|---|---|
| `createAnimation` (tween) | ✅ `DynamicValue` | ✅ `DynamicValue` | single `(value, velocity)` | ✅ `DynamicValue` |
| `createAnimation` (keyframes) | — (first keyframe) | — (last keyframe) | single `(value, velocity)` | — (sum of gaps) |
| `createTimeline` | — | — | multi `(values[], velocities[])` | — (computed) |
| `createSpring` | — (chases current) | ✅ `() => number` | single `(value, velocity)` | — (physics params) |
| `createSmoothDamp` | — (chases current) | ✅ `() => number` | single `(value, velocity)` | ✅ `DynamicValue` |
| `createLerp` | — (chases current) | ✅ `() => number` | single `(value, velocity)` | ✅ `DynamicValue` |
### Shared options
```ts
type DynamicValue = number | (() => number);
type ExternalTicker = {
add: (handler: TickHandler) => void;
remove: (handler: TickHandler) => void;
};
```
---
## Examples
### Single tween
```ts
const anim = createAnimation({
from: 0,
to: 100,
durationMs: 2000,
ease: "outElastic",
onUpdate: (v) => (sprite.x = v),
});
await anim.play();
```
### Keyframes
```ts
createAnimation({
keyframes: [{ value: 0 }, { value: 50, gap: 300, ease: "outCubic" }, { value: 100, gap: 400 }],
onUpdate: (v) => (sprite.x = v),
}).play();
```
### Repeat & yoyo
```ts
const anim = createAnimation({
from: () => (forward ? 1 : 1.3),
to: () => (forward ? 1.3 : 1),
durationMs: 600,
ease: "inOutSine",
onUpdate: (v) => sprite.scale.set(v),
});
for (let i = 0; i < 6; i++) {
forward = !forward;
await anim.play();
}
```
### Timeline
```ts
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),
},
},
],
{
// values[i] / velocities[i] correspond to layer i in definition order
onUpdate: (values, velocities) => {
sprite.x = values[0];
sprite.y = values[1];
},
onProgress: (p) => console.log(p),
},
);
```
### Spring
```ts
const spring = createSpring({
to: () => mouseX,
stiffness: 180,
damping: 12,
onUpdate: (v) => (sprite.x = v),
});
```
### Smooth damp
```ts
createSmoothDamp({
to: () => 100,
smoothTimeMs: 300,
onUpdate: (v) => (sprite.x = v),
});
```
### Lerp
```ts
createLerp({
to: () => 100,
smoothTimeMs: 300,
onUpdate: (v) => (sprite.x = v),
});
```
### Smooth clamp
```ts
const clamp = createSmoothClamp(45);
clamp(1000); // ≈ 44.96 (asymptotic to 45)
```
### Inertia (drag-to-throw)
Call `track()` every frame while dragging, then `release()` to coast.
```ts
const inertia = createInertia({
decelerationMs: 300,
precision: 0.5,
onUpdate: (v) => (block.style.left = `${v}px`),
});
function onPointerDown(e) {
inertia.track(e.clientX);
}
function onPointerMove(e) {
if (e.buttons) inertia.track(e.clientX);
}
function onPointerUp() {
inertia.release();
}
// Teleport and reset velocity instantly
inertia.setValue(30);
```
### Smooth scroll (momentum scroll)
Delta-driven momentum scroll with optional bounds. Target is clamped on write so there's no dead zone when coming back from overscroll.
```ts
const scroll = createSmoothScroll({
smoothTimeMs: 30,
min: 0,
max: 650,
onUpdate: (v) => (track.style.transform = `translateX(${-v}px)`),
});
// Wheel
viewport.addEventListener("wheel", (e) => {
scroll.addDelta(e.deltaX || e.deltaY);
});
// Pointer drag
function onPointerDown(e) {
scroll.setValue(scroll.value); // re-sync on grab
}
function onPointerMove(e) {
if (e.buttons) scroll.addDelta(e.clientX - lastX);
}
```
---
## Easing
31 named presets plus custom cubic bezier:
```ts
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";
type EaseFunction = (t: number) => number;
```
Custom bezier:
```ts
const ease = cubicBezier(0.25, 0.1, 0.25, 1);
createAnimation({ from: 0, to: 100, durationMs: 1000, ease, ... });
```
---
## Dynamic values
`DynamicValue` (`number | (() => number)`) is resolved at different frequencies depending on the primitive:
- **Timed animations** — resolved once per `play()` call and cached for the run.
- **Continuous primitives** — resolved every frame (targets are live).
```ts
// Resolved at play time, cached for duration
createAnimation({
from: () => getX(),
durationMs: () => 500 / speed,
keyframes: [{ value: 0 }, { value: () => targetY, gap: 300 }],
}).play();
// Resolved every frame
createSpring({ to: () => mouseX, ... });
```
---
## Color
Oklab (perceptually uniform) color interpolation via `lerpRgba`.
```ts
type RgbaTuple = [number, number, number, number]
hexToRgba(hex: string): RgbaTuple
lerpRgba(from: RgbaTuple, to: RgbaTuple, t: number): RgbaTuple
```
```ts
const from = hexToRgba("#ff6b6b"); // [1, 0.42, 0.42, 1]
const to = hexToRgba("#4ecdc4");
createAnimation({
from: 0,
to: 1,
durationMs: 2000,
onUpdate: (t) => {
const [r, g, b, a] = lerpRgba(from, to, t);
sprite.setColor(r, g, b, a);
},
});
```
Accepts `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA` formats.
---
## Ticker
Does **not** auto-start. Explicit control. **Call `getTicker().start()` once per application** — at your entry point, not inside a component or per-animation.
```ts
import { getTicker } from "anim-engine";
// rAF mode
getTicker().start();
// Custom game loop
const ticker = getTicker();
function loop(dt: number) {
ticker.update(dt);
}
```
Stop with `getTicker().stop()`.
---
## Rendering Library integration
### PixiJS
```ts
import { createAnimation, getTicker } from "anim-engine";
const ticker = getTicker();
app.ticker.add((delta) => ticker.update(delta.deltaMS));
createAnimation({
from: 0,
to: 300,
durationMs: 2000,
ease: "outElastic",
onUpdate: (x) => (sprite.x = x),
}).play();
```
### ThreeJS
```ts
const clock = new THREE.Clock();
const ticker = getTicker();
function animate() {
requestAnimationFrame(animate);
ticker.update(clock.getDelta() * 1000);
renderer.render(scene, camera);
}
animate();
```
---
## Benchmarks
vs GSAP (vitest bench, Apple Silicon M-series, Node 24). Matched easing functions.
| Benchmark | anim-engine | GSAP | Ratio |
| ----------------------------------- | ------------ | ------------ | ----- |
| Single tween (cubic, 1000 frames) | 40,974 ops/s | 10,656 ops/s | 3.8× |
| Single tween (bezier, 1000 frames) | 20,190 ops/s | 11,948 ops/s | 1.7× |
| Keyframe (3 segments, 1000 frames) | 26,661 ops/s | 3,578 ops/s | 7.5× |
| 50 concurrent tweens (500 frames) | 866 ops/s | 436 ops/s | 2.0× |
| 200 concurrent tweens (500 frames) | 187 ops/s | 112 ops/s | 1.7× |
| 1000 concurrent tweens (500 frames) | 37 ops/s | 13 ops/s | 2.8× |
| 50-layer timeline (500 frames) | 684 ops/s | 406 ops/s | 1.7× |
| 50 tweens re-play (500 frames) | 523 ops/s | 133 ops/s | 3.9× |
vs GSAP `onUpdate` (apples-to-apples): **2.8–4.5× faster**.
Run locally: `npm run bench`
---
## License
MIT