anim-engine 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,587 +5,445 @@
5
5
  [![NPM Version](https://img.shields.io/npm/v/anim-engine.svg)](https://www.npmjs.com/package/anim-engine)
6
6
  [![License](https://img.shields.io/npm/l/anim-engine.svg)](https://github.com/LukeCarlThompson/anim-engine/blob/main/packages/anim-engine/LICENSE)
7
7
  [![CI](https://github.com/LukeCarlThompson/anim-engine/actions/workflows/ci.yml/badge.svg)](https://github.com/LukeCarlThompson/anim-engine/actions)
8
- [![TypeScript](https://img.shields.io/badge/TypeScript-6.0-blue)](https://www.typescriptlang.org/)
9
8
 
10
9
  </div>
11
10
 
12
- **Renderer-agnostic animation for JavaScript runtimes.** A fast, lightweight, pure-numeric animation engine.
13
-
14
- - [Getting started](#getting-started)
15
- - [Design](#design)
16
- - [Key advantages](#key-advantages)
17
- - [Primitives](#primitives)
18
- - [Usage](#usage)
19
- - [createAnimation (tween)](#createanimation)
20
- - [createAnimation (keyframes)](#keyframes)
21
- - [Re-playing and sequencing](#re-playing-and-sequencing)
22
- - [createTimeline](#createtimeline)
23
- - [Continuous primitives](#continuous-primitives)
24
- - [createSmoothClamp](#createsmoothclamp)
25
- - [Color](#color)
26
- - [Ticker](#ticker)
27
- - [Easing](#easing)
28
- - [Dynamic values](#dynamic-values)
29
- - [Benchmarks](#benchmarks)
30
- - [Game engine integration](#game-engine-integration)
31
- - [API Reference](#api-reference)
32
- - [License](#license)
33
-
34
- ## Getting started
11
+ **Renderer-agnostic animation for JavaScript runtimes.** Pure-numeric, tree-shakeable, zero dependencies.
35
12
 
36
13
  ```sh
37
14
  npm install anim-engine
38
15
  ```
39
16
 
40
- ESM only. Tree-shakeable — import only what you use.
41
-
42
17
  ```ts
43
18
  import { createAnimation, getTicker } from "anim-engine";
44
19
 
45
- // The ticker drives all animations. Start it once or drive it with an external ticker.
46
20
  getTicker().start();
47
21
 
48
22
  const anim = createAnimation({
49
- from: 0,
50
- to: 100,
51
- durationMs: 1000,
52
- ease: "outCubic",
23
+ from: 0, to: 100, durationMs: 1000, ease: "outCubic",
53
24
  onUpdate: (value) => (sprite.x = value),
54
25
  });
55
26
 
56
27
  await anim.play();
57
28
  ```
58
29
 
59
- ## Design
30
+ > AI agents: this package includes a [`SKILL.md`](SKILL.md) with in-depth usage guidance for agent-assisted development.
60
31
 
61
- Anim Engine is built around three simple mental models that compose naturally:
32
+ ## Design
62
33
 
63
- - **Animation** a timed tween from value A to value B with easing and delay. The atomic unit of motion.
64
- - **Keyframes** — multi-segment interpolation that describes what a single value does over time, with per-segment easing and millisecond timing.
65
- - **Timeline** — orchestration of multiple animations running in parallel, sequence, or staggered offset. Composes tweens and keyframes.
34
+ Anim Engine is built around the same concepts used in animation applications, applied to pure numbers.
66
35
 
67
- Beyond timed animation, the engine provides **continuous primitives** — spring physics, smooth damp, and exponential lerp — for natural, target-chasing motion without a fixed duration.
36
+ **Timed motion** — a fixed path evaluated when `play()` is called.
68
37
 
69
- ### Why numbers only?
38
+ - **Tween** animate from value A to value B over a duration with easing.
39
+ - **Keyframes** — multi-segment motion describing a value at specific points in time.
40
+ - **Timeline** — orchestrate multiple keyframe animations in parallel, sequence, or staggered offset.
70
41
 
71
- By restricting itself to numeric values, the engine eliminates string parsing, color-space branching, and transform-matrix overhead. You get a minimal surface area that is easy to optimise, tree-shake, reason about and fit into any renderer. Color interpolation (Oklab) is provided as a pure function — you compose it yourself into an `onUpdate` callback.
42
+ **Continuous motion** chases a live target every frame.
72
43
 
73
- ## Key advantages
44
+ - **Spring** — mass-spring-damper physics for bouncy or elastic movement.
45
+ - **Smooth damp** — natural deceleration toward a target (Unity-style).
46
+ - **Lerp** — exponential approach for smooth asymptotic tracking.
47
+ - **Smooth clamp** — asymptotic saturation for capping velocity, torque, or force.
74
48
 
75
- | | |
76
- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
77
- | ⚡ **Fast** | 1.7–7.5× faster than GSAP overall; 3.0–6.2× when comparing equivalent `onUpdate` callback dispatch (see [benchmarks](#benchmarks)). |
78
- | 🪶 **Lightweight** | Tree-shakeable ESM — import only what you use. No DOM, no canvas, no dependencies. |
79
- | 🎯 **Numbers only** | A single numeric type for all values. No strings, no transforms, no branches. Predictable performance. |
80
- | 🔌 **Renderer-agnostic** | Feed values to PixiJS, ThreeJS, DOM, canvas2d, or WebGL. Same API everywhere. |
81
- | 🧩 **Composable models** | Animations, keyframes, and timelines compose cleanly — put tweens inside timelines, nest keyframes anywhere. |
82
- | 🔄 **Continuous primitives** | Spring, smooth damp, and lerp chase live targets with zero setup — pass `() => value` for dynamic targets. |
83
- | 🚫 **No GC pressure** | Zero object allocations in hot update paths. State is mutated in place. |
84
- | 🎨 **Perceptual color** | Oklab interpolation via `lerpRgba` — no muddy browns. Compose it into any `onUpdate`. |
85
- | 📐 **TypeScript-first** | Full type exports, exhaustive discriminated unions, no `any`. |
86
- | 🔧 **Ticker control** | Bring your own game loop or use the built-in rAF ticker. Explicit — never auto-starts. |
49
+ **Color helpers** — Oklab-based composable interpolation.
87
50
 
88
- ## Primitives
51
+ - **`lerpRgba`** — interpolates between two RGBA colors in perceptually uniform space.
52
+ - **`hexToRgba`** — parse hex color strings (`#RGB`, `#RRGGBB`, etc.) to normalized RGBA.
89
53
 
90
- ### Timed (return `Animation`)
54
+ ### Deliberately decoupled
91
55
 
92
- | Primitive | Returns | Description |
93
- | ------------------------------------------- | ----------- | ---------------------------------------------------- |
94
- | [`createAnimation`](#createanimation) | `Animation` | Timed tween from A to B with easing and delay |
95
- | [`createAnimation` (keyframes)](#keyframes) | `Animation` | Multi-segment interpolation with per-segment easing |
96
- | [`createTimeline`](#createtimeline) | `Timeline` | Orchestrate multiple animations on a shared timeline |
56
+ 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.
97
57
 
98
- Animations have `play()`/`pause()`/`resume()`/`stop()`/`skipToEnd()`/`kill()` controls, return a `Promise` from `play()`, and emit `onUpdate`/`onEnded`/`onProgress` callbacks.
58
+ - [Primitives](#primitives)
59
+ - [At a glance](#at-a-glance)
60
+ - [Examples](#examples)
61
+ - [Easing](#easing)
62
+ - [Dynamic values](#dynamic-values)
63
+ - [Color](#color)
64
+ - [Ticker](#ticker)
65
+ - [Game engine integration](#game-engine-integration)
66
+ - [Benchmarks](#benchmarks)
99
67
 
100
- ### Continuous (return `Interpolation`)
68
+ ---
101
69
 
102
- | Primitive | Returns | Description |
103
- | --------------------------------------- | --------------- | ---------------------------------------------------- |
104
- | [`createSpring`](#createspring) | `Interpolation` | Physics-based spring (Verlet integration) |
105
- | [`createSmoothDamp`](#createsmoothdamp) | `Interpolation` | Unity-style smooth damp, parameter-free chase |
106
- | [`createLerp`](#createlerp) | `Interpolation` | First-order exponential chase, single rate parameter |
70
+ ## Primitives
107
71
 
108
- Interpolations have `start()`/`stop()`/`kill()` controls, auto-start on creation, and chase a target without a fixed duration. No promise — they run until stopped.
72
+ Two families with complementary shapes:
109
73
 
110
- ### Utility
74
+ | Family | Functions | Lifespan |
75
+ |---|---|---|
76
+ | **Timed** | `createAnimation`, `createTimeline` | Fixed motion path, runs once per `play()` |
77
+ | **Continuous** | `createSpring`, `createSmoothDamp`, `createLerp` | Chases a live target until stopped |
111
78
 
112
- | Primitive | Returns | Description |
113
- | ----------------------------------------- | -------------------- | --------------------------------------------- |
114
- | [`createSmoothClamp`](#createsmoothclamp) | `(n: number) => n` | Asymptotic clamp — saturates toward threshold |
115
- | [`lerpRgba` / `hexToRgba`](#color) | `RgbaTuple` / parser | Perceptually uniform color interpolation |
79
+ All return a control handle with `value`, `velocity`, `status`, and lifecycle methods.
116
80
 
117
- ## Usage
81
+ `createSmoothClamp` is a pure function factory (no handle — returns `(input: number) => number`).
118
82
 
119
- ### createAnimation
83
+ ## At a glance
120
84
 
121
- Animate a single value from `from` to `to` over `durationMs`.
85
+ ### Timed `Animation`
122
86
 
123
87
  ```ts
124
- import { createAnimation } from "anim-engine";
125
-
126
- const anim = createAnimation({
127
- from: 0,
128
- to: 100,
129
- durationMs: 2000,
130
- ease: "outElastic",
131
- onUpdate: (value, velocity) => {
132
- sprite.x = value;
133
- },
134
- onEnded: () => console.log("done"),
135
- });
136
-
137
- // Promise-based control
138
- await anim.play(); // plays, resolves when done
139
- anim.pause();
140
- anim.resume();
141
- anim.stop(); // resets to start
142
- anim.skipToEnd(); // jumps to end, resolves promise
88
+ type Animation = {
89
+ play: () => Promise<void>
90
+ pause: () => void
91
+ resume: () => void
92
+ stop: () => void
93
+ skipToEnd: () => void
94
+ setProgress: (value: number) => void
95
+
96
+ value: number
97
+ velocity: number
98
+ progress: number // 0 → 1
99
+ status: "playing" | "paused" | "stopped"
100
+ durationMs: number
101
+ }
143
102
  ```
144
103
 
145
- **Single-tween options:**
146
-
147
- | Option | Type | Default | Description |
148
- | ------------ | ------------------------------------------- | ------------- | ------------------------------------------------------------ |
149
- | `from` | `number \| () => number` | — | Start value |
150
- | `to` | `number \| () => number` | — | End value |
151
- | `durationMs` | `number \| () => number` | — | Duration in milliseconds |
152
- | `ease` | `EaseName \| EaseFunction` | `"inOutSine"` | Easing function or name |
153
- | `onStarted` | `() => void` | — | Called when playback begins |
154
- | `onUpdate` | `(value: number, velocity: number) => void` | — | Called every frame with current value and velocity (units/s) |
155
- | `onEnded` | `() => void` | — | Called when animation completes |
156
-
157
- **Returns:** `Animation`
158
-
159
- ### Keyframes
160
-
161
- Multi-segment animation with per-keyframe easing. The first keyframe is the starting point (no gap/ease). Each subsequent keyframe specifies the gap from the previous one and an optional easing. Total duration is the sum of all gaps.
104
+ **Options — single tween:**
162
105
 
163
106
  ```ts
164
- import { createAnimation } from "anim-engine";
165
-
166
- const anim = createAnimation({
167
- keyframes: [
168
- { value: 0 },
169
- { value: 50, gap: 300, ease: "outCubic" },
170
- { value: 80, gap: 400, ease: "inOutQuad" },
171
- { value: 100, gap: 300 },
172
- ],
173
- onUpdate: (value) => (sprite.x = value),
174
- onProgress: (progress) => console.log(`${Math.round(progress * 100)}%`),
175
- });
107
+ type TweenOptions = {
108
+ from: DynamicValue
109
+ to: DynamicValue
110
+ durationMs: DynamicValue
111
+ ease?: EaseName | EaseFunction
112
+ onStarted?: () => void
113
+ onUpdate?: (value: number, velocity: number) => void
114
+ onProgress?: (progress: number) => void
115
+ onEnded?: () => void
116
+ ticker?: ExternalTicker
117
+ }
176
118
  ```
177
119
 
178
- Each keyframe's `gap` is in milliseconds from the previous keyframe. Total duration is the sum of all gaps (1000ms in this example). If no `ease` is specified on a keyframe, `"inOutSine"` is used as the default.
179
-
180
- **Keyframe options:**
181
-
182
- | Option | Type | Description |
183
- | ------------ | ------------------------------------------- | -------------------------------------------------- |
184
- | `keyframes` | `Keyframe[]` | Array of `{ value, gap?, ease? }` keyframes |
185
- | `onStarted` | `() => void` | Called when playback begins |
186
- | `onUpdate` | `(value: number, velocity: number) => void` | Called every frame with current value and velocity |
187
- | `onProgress` | `(progress: number) => void` | Called every frame with 0–1 global progress |
188
- | `onEnded` | `() => void` | Called when the keyframe animation completes |
189
-
190
- **Returns:** `Animation`
191
-
192
- ### Re-playing and sequencing
193
-
194
- Since `play()` can be called again after a tween completes, repeat and yoyo patterns are built from the public API rather than baked into the engine:
120
+ **Options keyframes:**
195
121
 
196
122
  ```ts
197
- import { createAnimation } from "anim-engine";
198
-
199
- const anim = createAnimation({
200
- from: 1,
201
- to: 1.3,
202
- durationMs: 600,
203
- ease: "inOutSine",
204
- onUpdate: (scale) => sprite.scale.set(scale),
205
- });
206
-
207
- // Repeat — just call play() again
208
- for (let i = 0; i < 3; i++) {
209
- await anim.play();
123
+ type KeyframeOptions = {
124
+ keyframes: Keyframe[]
125
+ onStarted?: () => void
126
+ onUpdate?: (value: number, velocity: number) => void
127
+ onProgress?: (progress: number) => void
128
+ onEnded?: () => void
129
+ ticker?: ExternalTicker
210
130
  }
211
131
 
212
- // Yoyo swap from/to via dynamic accessors and re-play
213
- let forward = true;
214
- const bounce = createAnimation({
215
- from: () => (forward ? 1 : 1.3),
216
- to: () => (forward ? 1.3 : 1),
217
- durationMs: 600,
218
- ease: "inOutSine",
219
- onUpdate: (v) => sprite.scale.set(v),
220
- });
221
-
222
- for (let i = 0; i < 6; i++) {
223
- forward = !forward;
224
- await bounce.play();
132
+ type Keyframe = {
133
+ value: DynamicValue
134
+ gap?: DynamicValue // ms from previous keyframe
135
+ ease?: EaseName | EaseFunction
225
136
  }
226
137
  ```
227
138
 
228
- For more complex sequences, use `createTimeline`:
229
-
230
- ```ts
231
- import { createTimeline } from "anim-engine";
232
-
233
- const flash = createTimeline([
234
- { at: 0, animation: { keyframes: [{ value: 0 }, { value: 1, gap: 300 }] } },
235
- { gap: 0, animation: { keyframes: [{ value: 1 }, { value: 0, gap: 300 }] } },
236
- ]);
237
- await flash.play();
238
- ```
239
-
240
- ### createTimeline
139
+ ### Timed `Timeline`
241
140
 
242
- Compose multiple keyframe animations on a shared timeline with `at` or `gap` positions. Parallelism comes from multiple layers at the same `at`.
141
+ Layers multiple keyframe animations in parallel or sequence.
243
142
 
244
143
  ```ts
245
- import { createTimeline } from "anim-engine";
246
-
247
- const timeline = createTimeline(
248
- [
249
- {
250
- at: 0,
251
- animation: {
252
- keyframes: [{ value: 0 }, { value: 1, gap: 500 }],
253
- onUpdate: (v) => (sprite.alpha = v),
254
- },
255
- },
256
- {
257
- at: 0,
258
- animation: {
259
- keyframes: [{ value: -100 }, { value: 0, gap: 800, ease: "outBack" }],
260
- onUpdate: (v) => (sprite.x = v),
261
- },
262
- },
263
- ],
264
- {
265
- onProgress: (progress) => console.log(`overall: ${progress}`),
266
- },
267
- );
268
-
269
- timeline.play();
144
+ type Timeline = {
145
+ play: () => Promise<void>
146
+ pause: () => void
147
+ resume: () => void
148
+ stop: () => void
149
+ skipToEnd: () => void
150
+ setProgress: (value: number) => void
151
+
152
+ values: number[] // one per layer
153
+ velocities: number[] // one per layer
154
+ progress: number
155
+ status: "playing" | "paused" | "stopped"
156
+ durationMs: number
157
+ }
270
158
  ```
271
159
 
272
- **Parameters:**
273
-
274
160
  ```ts
275
161
  type TimelineLayer =
276
- | { animation: KeyframeAnimationOptions; at: DynamicValue }
277
- | { animation: KeyframeAnimationOptions; gap: number };
162
+ | { animation: KeyframeOptions; at: DynamicValue } // absolute position
163
+ | { animation: KeyframeOptions; gap: number } // relative to previous layer end
278
164
  ```
279
165
 
280
- | Parameter | Type | Description |
281
- | -------------------- | -------------------- | -------------------------------------------------------------- |
282
- | `layers` | `TimelineLayer[]` | Array of layers with `at` (absolute) or `gap` (relative) start |
283
- | `options.onStarted` | `() => void` | Called when timeline begins |
284
- | `options.onProgress` | `(progress) => void` | Called every frame with overall 0–1 progress |
285
- | `options.onEnded` | `() => void` | Called when timeline finishes |
286
-
287
- `gap` is relative to the end of the previous layer. Use multiple layers at the same `at` for parallel animations. A simple tween is expressed as a 2-keyframe sequence: `{ value: from }, { value: to, gap: durationMs }`.
288
-
289
- ### Continuous primitives
290
-
291
- Spring, smooth damp, and lerp are **continuous** — they auto-start and chase a target. They return `Interpolation` (no `play`/`pause`/`promise`).
292
-
293
- #### createSpring
166
+ ### Continuous `Interpolation`
294
167
 
295
168
  ```ts
296
- import { createSpring } from "anim-engine";
169
+ type Interpolation = {
170
+ resume: () => void
171
+ stop: () => void
172
+ setValue: (value: number) => void
173
+
174
+ value: number
175
+ velocity: number
176
+ status: "active" | "inactive"
177
+ }
178
+ ```
297
179
 
298
- const spring = createSpring({
299
- to: () => 100,
300
- stiffness: 180,
301
- damping: 12,
302
- mass: 1,
303
- precision: 0.01,
304
- onUpdate: (value, velocity) => {
305
- sprite.x = value;
306
- },
307
- });
180
+ **Options:**
308
181
 
309
- spring.setCurrentValue(0); // jump to start — spring chases back to 100
182
+ ```ts
183
+ type SpringOptions = {
184
+ to: () => number
185
+ stiffness?: DynamicValue // default 180
186
+ damping?: DynamicValue // default 12
187
+ mass?: DynamicValue // default 1
188
+ precision?: number // default 0.01
189
+ onUpdate?: (value: number, velocity: number) => void
190
+ onEnded?: () => void
191
+ ticker?: ExternalTicker
192
+ }
310
193
 
311
- // Dynamic target — mouse chase
312
- const targetX = { value: 0 };
313
- document.addEventListener("mousemove", (e) => {
314
- targetX.value = e.clientX;
315
- });
194
+ type SmoothDampOptions = {
195
+ to: () => number
196
+ smoothTimeMs: DynamicValue
197
+ maxSpeed?: DynamicValue
198
+ precision?: number // default 0.01
199
+ onUpdate?: (value: number, velocity: number) => void
200
+ onEnded?: () => void
201
+ ticker?: ExternalTicker
202
+ }
316
203
 
317
- const follower = createSpring({
318
- to: () => targetX.value, // re-read every frame
319
- stiffness: 200,
320
- damping: 15,
321
- onUpdate: (v) => (sprite.x = v),
322
- });
204
+ type LerpOptions = {
205
+ to: () => number
206
+ smoothTimeMs: DynamicValue
207
+ precision?: number // default 0.01
208
+ onUpdate?: (value: number, velocity: number) => void
209
+ onEnded?: () => void
210
+ ticker?: ExternalTicker
211
+ }
323
212
  ```
324
213
 
325
- All parameters (`stiffness`, `damping`, `mass`, `to`) accept `number | (() => number)` — resolved every frame.
214
+ ### Common option patterns
326
215
 
327
- **Returns:** `Interpolation` `start()`, `stop()`, `kill()`, `setCurrentValue(value)`, `currentValue`, `velocity`, `status`. Starts at the `to()` value. Use `setCurrentValue(value)` to jump elsewhere.
216
+ | | `from` | `to` / target | `onUpdate` values | `durationMs` / `smoothTimeMs` |
217
+ |---|---|---|---|---|
218
+ | `createAnimation` (tween) | ✅ `DynamicValue` | ✅ `DynamicValue` | single `(value, velocity)` | ✅ `DynamicValue` |
219
+ | `createAnimation` (keyframes) | — (first keyframe) | — (last keyframe) | single `(value, velocity)` | — (sum of gaps) |
220
+ | `createTimeline` | — | — | multi `(values[], velocities[])` | — (computed) |
221
+ | `createSpring` | — (chases current) | ✅ `() => number` | single `(value, velocity)` | — (physics params) |
222
+ | `createSmoothDamp` | — (chases current) | ✅ `() => number` | single `(value, velocity)` | ✅ `DynamicValue` |
223
+ | `createLerp` | — (chases current) | ✅ `() => number` | single `(value, velocity)` | ✅ `DynamicValue` |
328
224
 
329
- #### createSmoothDamp
225
+ ### Shared options
330
226
 
331
227
  ```ts
332
- import { createSmoothDamp } from "anim-engine";
228
+ type DynamicValue = number | (() => number)
333
229
 
334
- const damp = createSmoothDamp({
335
- to: () => 100,
336
- smoothTimeMs: 300,
337
- maxSpeed: Infinity,
338
- onUpdate: (value, velocity) => (sprite.x = value),
339
- });
340
-
341
- damp.setCurrentValue(0); // jump to start — damp chases back to 100
230
+ type ExternalTicker = {
231
+ add: (handler: TickHandler) => void
232
+ remove: (handler: TickHandler) => void
233
+ }
342
234
  ```
343
235
 
344
- Unity-style smooth damp with Taylor-series exponential approximation. No stiffness/damping/mass to tune — just `smoothTimeMs` (milliseconds to reach target).
236
+ ---
345
237
 
346
- **Returns:** `Interpolation`
238
+ ## Examples
347
239
 
348
- #### createLerp
240
+ ### Single tween
349
241
 
350
242
  ```ts
351
- import { createLerp } from "anim-engine";
352
-
353
- const lerp = createLerp({
354
- to: () => 100,
355
- smoothTimeMs: 300, // ms to reach target
356
- onUpdate: (value, velocity) => (sprite.x = value),
243
+ const anim = createAnimation({
244
+ from: 0, to: 100, durationMs: 2000, ease: "outElastic",
245
+ onUpdate: (v) => (sprite.x = v),
357
246
  });
358
-
359
- lerp.setCurrentValue(0); // jump to start — lerp chases back to 100
247
+ await anim.play();
360
248
  ```
361
249
 
362
- First-order exponential approach: `value += (target - value) * rate * deltaTime`. `smoothTimeMs` is the approximate time in milliseconds to reach the target. Frame-rate independent.
363
-
364
- **Returns:** `Interpolation`
365
-
366
- ### createSmoothClamp
250
+ ### Keyframes
367
251
 
368
252
  ```ts
369
- import { createSmoothClamp } from "anim-engine";
370
-
371
- const clamp = createSmoothClamp(45); // threshold = 45 units/s
372
-
373
- const result = clamp(1000); // → ~44.96 (approaches 45 asymptotically)
374
- const result2 = clamp(-500); // → ~-44.96 (symmetric)
253
+ createAnimation({
254
+ keyframes: [
255
+ { value: 0 },
256
+ { value: 50, gap: 300, ease: "outCubic" },
257
+ { value: 100, gap: 400 },
258
+ ],
259
+ onUpdate: (v) => (sprite.x = v),
260
+ }).play();
375
261
  ```
376
262
 
377
- Uses `threshold * (normalized / (1 + |normalized|))` for asymptotic saturation. Handles `Infinity` correctly. Returns a function `(value: number) => number` with a `setCurrentValue(0)` method to reset position.
378
-
379
- ### Color
380
-
381
- Perceptually uniform Oklab interpolation. Straight RGB lerp produces muddy transitions — red→green goes through brown, blue→yellow goes through grey. Oklab matches human perception: equal steps look like equal changes.
263
+ ### Repeat & yoyo
382
264
 
383
265
  ```ts
384
- import { lerpRgba, hexToRgba } from "anim-engine";
266
+ const anim = createAnimation({
267
+ from: () => (forward ? 1 : 1.3),
268
+ to: () => (forward ? 1.3 : 1),
269
+ durationMs: 600, ease: "inOutSine",
270
+ onUpdate: (v) => sprite.scale.set(v),
271
+ });
385
272
 
386
- hexToRgba("#ff6b6b"); // [1, 0.42, 0.42, 1]
387
- hexToRgba("#f80"); // → [1, 0.533, 0, 1] (shorthand)
388
- hexToRgba("#ff804080"); // → [1, 0.502, 0.251, 0.502] (with alpha)
273
+ for (let i = 0; i < 6; i++) {
274
+ forward = !forward;
275
+ await anim.play();
276
+ }
277
+ ```
389
278
 
390
- const fromColor = hexToRgba("#ff6b6b");
391
- const toColor = hexToRgba("#4ecdc4");
279
+ ### Timeline
392
280
 
393
- createAnimation({
394
- from: 0,
395
- to: 1,
396
- durationMs: 2000,
397
- ease: "outCubic",
398
- onUpdate: (t) => {
399
- const [r, g, b, a] = lerpRgba(fromColor, toColor, t);
400
- sprite.setColor(r, g, b, a);
281
+ ```ts
282
+ createTimeline([
283
+ {
284
+ at: 0,
285
+ animation: {
286
+ keyframes: [{ value: 0 }, { value: 1, gap: 500 }],
287
+ onEnded: () => console.log("layer 1 done"),
288
+ },
289
+ },
290
+ {
291
+ at: 0,
292
+ animation: {
293
+ keyframes: [{ value: -100 }, { value: 0, gap: 800, ease: "outBack" }],
294
+ onUpdate: (v) => (sprite.y = v),
295
+ },
296
+ },
297
+ ], {
298
+ // values[i] / velocities[i] correspond to layer i in definition order
299
+ onUpdate: (values, velocities) => {
300
+ sprite.x = values[0];
301
+ sprite.y = values[1];
401
302
  },
303
+ onProgress: (p) => console.log(p),
402
304
  });
403
305
  ```
404
306
 
405
- **With continuous primitives** — drive the blend factor via spring, damp, or lerp:
307
+ ### Spring
406
308
 
407
309
  ```ts
408
- import { createSpring, lerpRgba, hexToRgba } from "anim-engine";
409
-
410
- const fromColor = hexToRgba("#ff6b6b");
411
- const toColor = hexToRgba("#4ecdc4");
412
-
413
310
  const spring = createSpring({
414
- from: 0,
415
- to: () => (isHovered ? 1 : 0),
416
- stiffness: 180,
417
- damping: 12,
418
- onUpdate: (t) => {
419
- const [r, g, b] = lerpRgba(fromColor, toColor, t);
420
- sprite.setColor(r, g, b, 1);
421
- },
311
+ to: () => mouseX,
312
+ stiffness: 180, damping: 12,
313
+ onUpdate: (v) => (sprite.x = v),
422
314
  });
423
315
  ```
424
316
 
425
- See [`src/color/README.md`](src/color/README.md) for the full Oklab reference including color-space internals.
426
-
427
- ## Ticker
428
-
429
- The ticker does **not** auto-start. You must explicitly call `start()` (for rAF) or `update(deltaMs)` (for custom game loops) to drive animations.
430
-
431
- Primitives register themselves with the ticker on creation — no manual registration needed.
432
-
433
- **Standalone (rAF):** `getTicker().start()` uses its own `requestAnimationFrame` loop. Best for DOM-based demos or when you don't have a game loop.
317
+ ### Smooth damp
434
318
 
435
319
  ```ts
436
- import { getTicker, createAnimation } from "anim-engine";
437
-
438
- getTicker().start(); // starts the rAF loop — call once
439
-
440
- createAnimation({
441
- from: 0,
442
- to: 100,
443
- durationMs: 2000,
444
- ease: "outElastic",
320
+ createSmoothDamp({
321
+ to: () => 100, smoothTimeMs: 300,
445
322
  onUpdate: (v) => (sprite.x = v),
446
- }).play();
323
+ });
447
324
  ```
448
325
 
449
- **Custom game loop:** Call `getTicker().update(deltaMs)` from your own loop. Syncs to PixiJS ticker, ThreeJS `requestAnimationFrame`, or a fixed-step physics loop.
326
+ ### Lerp
450
327
 
451
328
  ```ts
452
- import { getTicker } from "anim-engine";
329
+ createLerp({
330
+ to: () => 100, smoothTimeMs: 300,
331
+ onUpdate: (v) => (sprite.x = v),
332
+ });
333
+ ```
453
334
 
454
- const animEngineTicker = getTicker();
335
+ ### Smooth clamp
455
336
 
456
- // Call once per frame from your game loop
457
- function gameLoop(deltaMs: number) {
458
- animEngineTicker.update(deltaMs);
459
- // ... physics, rendering
460
- }
337
+ ```ts
338
+ const clamp = createSmoothClamp(45);
339
+ clamp(1000); // ≈ 44.96 (asymptotic to 45)
461
340
  ```
462
341
 
463
- **Stop:** Call `getTicker().stop()` or `animEngineTicker.stop()` to clean up the rAF loop.
342
+ ---
464
343
 
465
344
  ## Easing
466
345
 
467
- 31 Penner easing functions plus custom cubic bezier:
346
+ 31 named presets plus custom cubic bezier:
468
347
 
469
348
  ```ts
470
- import { cubicBezier, EASE_NAMES } from "anim-engine";
349
+ type EaseName =
350
+ | "linear" | "inQuad" | "outQuad" | "inOutQuad"
351
+ | "inCubic" | "outCubic" | "inOutCubic"
352
+ | "inQuart" | "outQuart" | "inOutQuart"
353
+ | "inQuint" | "outQuint" | "inOutQuint"
354
+ | "inSine" | "outSine" | "inOutSine"
355
+ | "inExpo" | "outExpo" | "inOutExpo"
356
+ | "inCirc" | "outCirc" | "inOutCirc"
357
+ | "inBack" | "outBack" | "inOutBack"
358
+ | "inElastic" | "outElastic" | "inOutElastic"
359
+ | "inBounce" | "outBounce" | "inOutBounce"
360
+
361
+ type EaseFunction = (t: number) => number
362
+ ```
471
363
 
472
- const customEase = cubicBezier(0.25, 0.1, 0.25, 1); // custom easing function
364
+ Custom bezier:
473
365
 
474
- // Use it anywhere you'd use an ease name
475
- const anim = createAnimation({
476
- from: 0,
477
- to: 100,
478
- durationMs: 1000,
479
- ease: customEase,
480
- onUpdate: (v) => (sprite.x = v),
481
- });
366
+ ```ts
367
+ const ease = cubicBezier(0.25, 0.1, 0.25, 1);
368
+ createAnimation({ from: 0, to: 100, durationMs: 1000, ease, ... });
482
369
  ```
483
370
 
484
- You can pass the result directly to any `ease` option — it's an `EaseFunction`, just like the named presets.
485
-
486
- **Supported ease names:** `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`.
371
+ ---
487
372
 
488
373
  ## Dynamic values
489
374
 
490
- The `DynamicValue` type (`number | (() => number)`) lets you provide values that are resolved at runtime. How often they're resolved depends on the primitive:
491
-
492
- ### Timed animations (per-play)
375
+ `DynamicValue` (`number | (() => number)`) is resolved at different frequencies depending on the primitive:
493
376
 
494
- In `createAnimation` tweens and keyframes all dynamic values are resolved **once when `play()` is called** and cached for the duration of that run. The frame-hot update path reads from the cache with zero overhead:
377
+ - **Timed animations** — resolved once per `play()` call and cached for the run.
378
+ - **Continuous primitives** — resolved every frame (targets are live).
495
379
 
496
380
  ```ts
497
- const anim = createAnimation({
498
- from: () => getLayoutStart(),
499
- to: () => getLayoutEnd(),
500
- durationMs: () => 500 / speedMultiplier,
501
- keyframes: [
502
- { at: 0, value: 0 },
503
- { at: () => scrollHeight * 0.3, value: 50 },
504
- { at: () => scrollHeight, value: 100 },
505
- ],
506
- });
507
-
508
- // Values resolved here, cached for duration
509
- await anim.play();
381
+ // Resolved at play time, cached for duration
382
+ createAnimation({
383
+ from: () => getX(),
384
+ durationMs: () => 500 / speed,
385
+ keyframes: [{ value: 0 }, { value: () => targetY, gap: 300 }],
386
+ }).play();
510
387
 
511
- // On re-play, values are re-resolved
512
- await anim.play();
388
+ // Resolved every frame
389
+ createSpring({ to: () => mouseX, ... });
513
390
  ```
514
391
 
515
- This means `from`, `to`, `durationMs`, `keyframe.at`, and `keyframe.value` are all evaluated at play time and remain stable throughout the animation. If you need truly per-frame dynamic values, that's the domain of continuous primitives.
392
+ ---
516
393
 
517
- ### Continuous primitives (per-frame)
394
+ ## Color
518
395
 
519
- In `createSpring`, `createSmoothDamp`, and `createLerp`, dynamic values are resolved **every frame** — these primitives are designed to chase live targets:
396
+ Oklab (perceptually uniform) color interpolation via `lerpRgba`.
520
397
 
521
398
  ```ts
522
- const spring = createSpring({
523
- to: () => getMousePosition(), // re-read every frame
524
- stiffness: () => sliderValue, // dynamic stiffness
525
- damping: () => dampingValue,
399
+ const from = hexToRgba("#ff6b6b"); // [1, 0.42, 0.42, 1]
400
+ const to = hexToRgba("#4ecdc4");
401
+
402
+ createAnimation({
403
+ from: 0, to: 1, durationMs: 2000,
404
+ onUpdate: (t) => {
405
+ const [r, g, b, a] = lerpRgba(from, to, t);
406
+ sprite.setColor(r, g, b, a);
407
+ },
526
408
  });
527
409
  ```
528
410
 
529
- This distinction reflects the two use-cases: timed animations describe a fixed motion path evaluated at start, while continuous primitives describe ongoing behaviour that adapts to changing input.
411
+ Accepts `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA` formats.
530
412
 
531
- ## Benchmarks
532
-
533
- Performance comparison against GSAP (vitest bench, Apple Silicon M-series, Node 24). All easing functions are matched between libraries.
413
+ ---
534
414
 
535
- ### vs GSAP internal mutation
415
+ ## Ticker
536
416
 
537
- GSAP defaults to mutating target properties directly. This is faster than dispatching callbacks but couples GSAP to the mutation pattern. Anim-engine always dispatches via `onUpdate` (renderer-agnostic).
417
+ Does **not** auto-start. Explicit control:
538
418
 
539
- | Benchmark | anim-engine | GSAP | Ratio |
540
- | --------------------------------------------- | ------------ | ------------ | ----------- |
541
- | **Single tween** (cubic, 1000 frames) | 40,814 ops/s | 10,776 ops/s | 3.8× faster |
542
- | **Single tween** (linear, 1000 frames) | 28,590 ops/s | 14,499 ops/s | 2.0× faster |
543
- | **Single tween** (cubic bezier, 1000 frames) | 21,449 ops/s | 12,637 ops/s | 1.7× faster |
544
- | **Keyframe** (3 segments, 1000 frames) | 26,741 ops/s | 3,549 ops/s | 7.5× faster |
545
- | **50 concurrent tweens** (500 frames) | 892 ops/s | 438 ops/s | 2.0× faster |
546
- | **200 concurrent tweens** (500 frames) | 200 ops/s | 106 ops/s | 1.9× faster |
547
- | **1000 concurrent tweens** (500 frames) | 38 ops/s | 22 ops/s | 1.7× faster |
548
- | **50 concurrent keyframes** (500 frames) | 934 ops/s | 174 ops/s | 5.4× faster |
549
- | **50-layer timeline** (staggered, 500 frames) | 710 ops/s | 404 ops/s | 1.8× faster |
550
- | **50 tweens re-play** (2 cycles, 500 frames) | 558 ops/s | 128 ops/s | 4.4× faster |
419
+ ```ts
420
+ import { getTicker } from "anim-engine";
551
421
 
552
- ### vs GSAP onUpdate (fair comparison)
422
+ // rAF mode
423
+ getTicker().start();
553
424
 
554
- gsap also supports `onUpdate` callbacks, which is equivalent to anim-engine's renderer-agnostic model. This is the apples-to-apples comparison.
425
+ // Custom game loop
426
+ const ticker = getTicker();
427
+ function loop(dt: number) {
428
+ ticker.update(dt);
429
+ }
430
+ ```
555
431
 
556
- | Benchmark | anim-engine | GSAP (onUpdate) | Ratio |
557
- | -------------------------------------- | ------------ | --------------- | ----------- |
558
- | **Single tween** (cubic, 1000 frames) | 40,814 ops/s | 6,618 ops/s | 6.2× faster |
559
- | **50 concurrent tweens** (500 frames) | 892 ops/s | 273 ops/s | 3.3× faster |
560
- | **200 concurrent tweens** (500 frames) | 200 ops/s | 66 ops/s | 3.0× faster |
432
+ Stop with `getTicker().stop()`.
561
433
 
562
- Run locally: `npm run bench`
434
+ ---
563
435
 
564
436
  ## Game engine integration
565
437
 
566
438
  ### PixiJS
567
439
 
568
440
  ```ts
569
- import { Application, Sprite } from "pixi.js";
570
441
  import { createAnimation, getTicker } from "anim-engine";
442
+ const ticker = getTicker();
571
443
 
572
- const app = new Application();
573
- await app.init({ width: 800, height: 600 });
574
-
575
- const sprite = Sprite.from("texture.png");
576
- app.stage.addChild(sprite);
577
-
578
- // Sync anim-engine ticker to PixiJS ticker
579
- const animEngineTicker = getTicker();
580
- app.ticker.add((delta) => {
581
- animEngineTicker.update(delta.deltaMS);
582
- });
444
+ app.ticker.add((delta) => ticker.update(delta.deltaMS));
583
445
 
584
- createAnimation({
585
- from: 0,
586
- to: 300,
587
- durationMs: 2000,
588
- ease: "outElastic",
446
+ createAnimation({ from: 0, to: 300, durationMs: 2000, ease: "outElastic",
589
447
  onUpdate: (x) => (sprite.x = x),
590
448
  }).play();
591
449
  ```
@@ -593,95 +451,39 @@ createAnimation({
593
451
  ### ThreeJS
594
452
 
595
453
  ```ts
596
- import * as THREE from "three";
597
- import { createAnimation, getTicker, createSmoothDamp } from "anim-engine";
598
-
599
- const scene = new THREE.Scene();
600
- const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000);
601
- const renderer = new THREE.WebGLRenderer();
602
-
603
- const mesh = new THREE.Mesh(
604
- new THREE.BoxGeometry(),
605
- new THREE.MeshStandardMaterial({ color: "#667eea" }),
606
- );
607
- scene.add(mesh);
608
-
609
- const animEngineTicker = getTicker();
610
454
  const clock = new THREE.Clock();
455
+ const ticker = getTicker();
611
456
 
612
457
  function animate() {
613
458
  requestAnimationFrame(animate);
614
- animEngineTicker.update(clock.getDelta() * 1000);
459
+ ticker.update(clock.getDelta() * 1000);
615
460
  renderer.render(scene, camera);
616
461
  }
617
462
  animate();
618
-
619
- createAnimation({
620
- from: 0,
621
- to: Math.PI * 2,
622
- durationMs: 3000,
623
- ease: "inOutCubic",
624
- onUpdate: (angle) => (mesh.rotation.y = angle),
625
- }).play();
626
463
  ```
627
464
 
628
- ### Custom game loop
465
+ ---
629
466
 
630
- ```ts
631
- import { getTicker, createSpring } from "anim-engine";
467
+ ## Benchmarks
632
468
 
633
- const animEngineTicker = getTicker();
469
+ vs GSAP (vitest bench, Apple Silicon M-series, Node 24). Matched easing functions.
634
470
 
635
- function gameLoop(timestamp: number) {
636
- const deltaMs = timestamp - lastTimestamp;
637
- lastTimestamp = timestamp;
471
+ | Benchmark | anim-engine | GSAP | Ratio |
472
+ |---|---|---|---|
473
+ | Single tween (cubic, 1000 frames) | 40,974 ops/s | 10,656 ops/s | 3.8× |
474
+ | Single tween (bezier, 1000 frames) | 20,190 ops/s | 11,948 ops/s | 1.7× |
475
+ | Keyframe (3 segments, 1000 frames) | 26,661 ops/s | 3,578 ops/s | 7.5× |
476
+ | 50 concurrent tweens (500 frames) | 866 ops/s | 436 ops/s | 2.0× |
477
+ | 200 concurrent tweens (500 frames) | 187 ops/s | 112 ops/s | 1.7× |
478
+ | 1000 concurrent tweens (500 frames) | 37 ops/s | 13 ops/s | 2.8× |
479
+ | 50-layer timeline (500 frames) | 684 ops/s | 406 ops/s | 1.7× |
480
+ | 50 tweens re-play (500 frames) | 523 ops/s | 133 ops/s | 3.9× |
638
481
 
639
- animEngineTicker.update(deltaMs);
640
- // ... physics, rendering, etc.
482
+ vs GSAP `onUpdate` (apples-to-apples): **2.8–4.5× faster**.
641
483
 
642
- requestAnimationFrame(gameLoop);
643
- }
644
- requestAnimationFrame(gameLoop);
645
- ```
484
+ Run locally: `npm run bench`
646
485
 
647
- ## API Reference
648
-
649
- ### Functions
650
-
651
- | Export | Description |
652
- | ---------------------------------- | ----------------------------------- |
653
- | `createAnimation(options)` | Timed or keyframe animation |
654
- | `createTimeline(layers, options?)` | Composited timeline of animations |
655
- | `createSpring(options)` | Physics spring (Verlet integration) |
656
- | `createSmoothDamp(options)` | Unity-style smooth damp |
657
- | `createLerp(options)` | Exponential lerp chase |
658
- | `createSmoothClamp(threshold)` | Asymptotic clamp factory |
659
- | `getTicker()` | Singleton ticker |
660
- | `cubicBezier(p1x, p1y, p2x, p2y)` | Custom cubic bezier easing |
661
- | `lerpRgba(from, to, t)` | Oklab color interpolation |
662
- | `hexToRgba(hex)` | Parse hex color to normalized RGBA |
663
-
664
- ### Type exports
665
-
666
- | Type | Description |
667
- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
668
- | `Animation` | `play`, `pause`, `resume`, `stop`, `skipToEnd`, `kill`, `setProgress`, `currentValue`, `velocity`, `progress`, `status` |
669
- | `Interpolation` | `start`, `stop`, `kill`, `setCurrentValue`, `currentValue`, `velocity`, `status` |
670
- | `Timeline` | `play`, `pause`, `resume`, `stop`, `skipToEnd`, `kill`, `setProgress`, `progress`, `status` |
671
- | `EaseName` | Union of 31 ease name strings |
672
- | `EaseFunction` | `(t: number) => number` |
673
- | `DynamicValue` | `number \| (() => number)` |
674
- | `AnimationStatus` | `"playing" \| "paused" \| "stopped" \| "dead"` (for `Animation` / `Timeline`) |
675
- | `InterpolationStatus` | `"active" \| "inactive" \| "dead"` (for `Interpolation`) |
676
- | `AnimationOptions` | Single tween or keyframe animation options (discriminated union) |
677
- | `Keyframe` | `{ value, gap?, ease? }` |
678
- | `KeyframeAnimationOptions` | `{ keyframes: Keyframe[], onStarted?, onUpdate?, onProgress?, onEnded? }` |
679
- | `TimelineLayer` | `{ animation: KeyframeAnimationOptions; at: DynamicValue } \| { animation: KeyframeAnimationOptions; gap: number }` |
680
- | `SpringOptions` | `to`, `stiffness`, `damping`, `mass`, `precision?`, `onUpdate`, `onEnded` |
681
- | `SmoothDampOptions` | `to`, `smoothTimeMs`, `maxSpeed?`, `precision?`, `onUpdate`, `onEnded` |
682
- | `LerpOptions` | `to`, `smoothTimeMs`, `precision?`, `onUpdate`, `onEnded` |
683
- | `RgbaTuple` | `readonly [number, number, number, number]` |
684
- | `Ticker` | `start`, `stop`, `update`, `add`, `remove` |
486
+ ---
685
487
 
686
488
  ## License
687
489