anim-engine 0.3.2 → 0.5.0

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.
Files changed (46) hide show
  1. package/README.md +283 -499
  2. package/dist/animation/create-animation.d.ts +12 -0
  3. package/dist/animation/create-animation.js +19 -23
  4. package/dist/animation/create-single-tween.d.ts +1 -2
  5. package/dist/animation/index.d.ts +1 -0
  6. package/dist/animation/runner.d.ts +1 -1
  7. package/dist/animation/runner.js +22 -22
  8. package/dist/animation/update.d.ts +1 -1
  9. package/dist/animation/update.js +4 -4
  10. package/dist/domain/animation.d.ts +118 -3
  11. package/dist/domain/color.d.ts +14 -0
  12. package/dist/domain/easing.d.ts +12 -0
  13. package/dist/domain/easing.js +9 -35
  14. package/dist/domain/index.d.ts +4 -4
  15. package/dist/domain/interpolation.d.ts +153 -5
  16. package/dist/domain/resolve-value.d.ts +13 -0
  17. package/dist/domain/resolve-value.js +8 -0
  18. package/dist/domain/ticker.d.ts +26 -14
  19. package/dist/domain/timeline.d.ts +47 -2
  20. package/dist/index.d.ts +10 -13
  21. package/dist/index.js +8 -7
  22. package/dist/lerp/create-lerp.d.ts +14 -9
  23. package/dist/lerp/create-lerp.js +21 -18
  24. package/dist/lerp/index.d.ts +1 -0
  25. package/dist/lerp-rgba/hex-to-rgba.d.ts +13 -0
  26. package/dist/lerp-rgba/hex-to-rgba.js +48 -0
  27. package/dist/lerp-rgba/index.d.ts +2 -0
  28. package/dist/lerp-rgba/lerp-rgba.d.ts +13 -0
  29. package/dist/{color/lerp-oklab.js → lerp-rgba/lerp-rgba.js} +3 -48
  30. package/dist/smooth-clamp/{smooth-clamp.js → create-smooth-clamp.js} +1 -1
  31. package/dist/smooth-clamp/index.d.ts +1 -0
  32. package/dist/smooth-damp/create-smooth-damp.d.ts +16 -10
  33. package/dist/smooth-damp/create-smooth-damp.js +34 -29
  34. package/dist/smooth-damp/index.d.ts +1 -0
  35. package/dist/spring/create-spring.d.ts +13 -11
  36. package/dist/spring/create-spring.js +17 -19
  37. package/dist/spring/index.d.ts +1 -0
  38. package/dist/ticker/get-ticker.d.ts +25 -0
  39. package/dist/{domain/ticker.js → ticker/get-ticker.js} +10 -2
  40. package/dist/ticker/index.d.ts +1 -0
  41. package/dist/timeline/create-timeline.d.ts +16 -6
  42. package/dist/timeline/create-timeline.js +41 -13
  43. package/dist/timeline/index.d.ts +1 -0
  44. package/package.json +1 -1
  45. package/dist/color/lerp-oklab.d.ts +0 -26
  46. /package/dist/smooth-clamp/{smooth-clamp.d.ts → create-smooth-clamp.d.ts} +0 -0
package/README.md CHANGED
@@ -5,51 +5,22 @@
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
 
@@ -58,534 +29,403 @@ await anim.play();
58
29
 
59
30
  ## Design
60
31
 
61
- Anim Engine is built around three simple mental models that compose naturally:
32
+ Anim Engine is built around the same concepts used in animation applications, applied to pure numbers.
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
+ **Timed motion** — a fixed path evaluated when `play()` is called.
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
+ - **Tween** — animate from value A to value B over a duration with easing.
37
+ - **Keyframes** — multi-segment motion describing a value at specific points in time.
38
+ - **Timeline** — orchestrate multiple keyframe animations in parallel, sequence, or staggered offset.
68
39
 
69
- ### Why numbers only?
40
+ **Continuous motion** chases a live target every frame.
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
+ - **Spring** mass-spring-damper physics for bouncy or elastic movement.
43
+ - **Smooth damp** — natural deceleration toward a target (Unity-style).
44
+ - **Lerp** — exponential approach for smooth asymptotic tracking.
45
+ - **Smooth clamp** — asymptotic saturation for capping velocity, torque, or force.
72
46
 
73
- ## Key advantages
47
+ **Color helpers** — Oklab-based composable interpolation.
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 `lerpOklab` — 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
+ - **`lerpRgba`** — interpolates between two RGBA colors in perceptually uniform space.
50
+ - **`hexToRgba`** parse hex color strings (`#RGB`, `#RRGGBB`, etc.) to normalized RGBA.
87
51
 
88
- ## Primitives
89
-
90
- ### Timed (return `Animation`)
52
+ ### Deliberately decoupled
91
53
 
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 |
54
+ 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
55
 
98
- Animations have `play()`/`pause()`/`resume()`/`stop()`/`skipToEnd()`/`kill()` controls, return a `Promise` from `play()`, and emit `onUpdate`/`onEnded`/`onProgress` callbacks.
56
+ - [Primitives](#primitives)
57
+ - [At a glance](#at-a-glance)
58
+ - [Examples](#examples)
59
+ - [Easing](#easing)
60
+ - [Dynamic values](#dynamic-values)
61
+ - [Color](#color)
62
+ - [Ticker](#ticker)
63
+ - [Game engine integration](#game-engine-integration)
64
+ - [Benchmarks](#benchmarks)
99
65
 
100
- ### Continuous (return `Interpolation`)
66
+ ---
101
67
 
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 |
68
+ ## Primitives
107
69
 
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.
70
+ Two families with complementary shapes:
109
71
 
110
- ### Utility
72
+ | Family | Functions | Lifespan |
73
+ |---|---|---|
74
+ | **Timed** | `createAnimation`, `createTimeline` | Fixed motion path, runs once per `play()` |
75
+ | **Continuous** | `createSpring`, `createSmoothDamp`, `createLerp` | Chases a live target until stopped |
111
76
 
112
- | Primitive | Returns | Description |
113
- | ----------------------------------------- | -------------------- | --------------------------------------------- |
114
- | [`createSmoothClamp`](#createsmoothclamp) | `(n: number) => n` | Asymptotic clamp — saturates toward threshold |
115
- | [`lerpOklab` / `hexToRgba`](#color) | `RgbaTuple` / parser | Perceptually uniform color interpolation |
77
+ All return a control handle with `value`, `velocity`, `status`, and lifecycle methods.
116
78
 
117
- ## Usage
79
+ `createSmoothClamp` is a pure function factory (no handle — returns `(input: number) => number`).
118
80
 
119
- ### createAnimation
81
+ ## At a glance
120
82
 
121
- Animate a single value from `from` to `to` over `durationMs`.
83
+ ### Timed `Animation`
122
84
 
123
85
  ```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
86
+ type Animation = {
87
+ play: () => Promise<void>
88
+ pause: () => void
89
+ resume: () => void
90
+ stop: () => void
91
+ skipToEnd: () => void
92
+ setProgress: (value: number) => void
93
+
94
+ value: number
95
+ velocity: number
96
+ progress: number // 0 → 1
97
+ status: "playing" | "paused" | "stopped"
98
+ durationMs: number
99
+ }
143
100
  ```
144
101
 
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.
102
+ **Options — single tween:**
162
103
 
163
104
  ```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
- });
105
+ type TweenOptions = {
106
+ from: DynamicValue
107
+ to: DynamicValue
108
+ durationMs: DynamicValue
109
+ ease?: EaseName | EaseFunction
110
+ onStarted?: () => void
111
+ onUpdate?: (value: number, velocity: number) => void
112
+ onProgress?: (progress: number) => void
113
+ onEnded?: () => void
114
+ ticker?: ExternalTicker
115
+ }
176
116
  ```
177
117
 
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:
118
+ **Options keyframes:**
195
119
 
196
120
  ```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();
121
+ type KeyframeOptions = {
122
+ keyframes: Keyframe[]
123
+ onStarted?: () => void
124
+ onUpdate?: (value: number, velocity: number) => void
125
+ onProgress?: (progress: number) => void
126
+ onEnded?: () => void
127
+ ticker?: ExternalTicker
210
128
  }
211
129
 
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();
130
+ type Keyframe = {
131
+ value: DynamicValue
132
+ gap?: DynamicValue // ms from previous keyframe
133
+ ease?: EaseName | EaseFunction
225
134
  }
226
135
  ```
227
136
 
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
- ```
137
+ ### Timed `Timeline`
239
138
 
240
- ### createTimeline
241
-
242
- Compose multiple keyframe animations on a shared timeline with `at` or `gap` positions. Parallelism comes from multiple layers at the same `at`.
139
+ Layers multiple keyframe animations in parallel or sequence.
243
140
 
244
141
  ```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();
142
+ type Timeline = {
143
+ play: () => Promise<void>
144
+ pause: () => void
145
+ resume: () => void
146
+ stop: () => void
147
+ skipToEnd: () => void
148
+ setProgress: (value: number) => void
149
+
150
+ values: number[] // one per layer
151
+ velocities: number[] // one per layer
152
+ progress: number
153
+ status: "playing" | "paused" | "stopped"
154
+ durationMs: number
155
+ }
270
156
  ```
271
157
 
272
- **Parameters:**
273
-
274
158
  ```ts
275
159
  type TimelineLayer =
276
- | { animation: KeyframeAnimationOptions; at: DynamicValue }
277
- | { animation: KeyframeAnimationOptions; gap: number };
160
+ | { animation: KeyframeOptions; at: DynamicValue } // absolute position
161
+ | { animation: KeyframeOptions; gap: number } // relative to previous layer end
278
162
  ```
279
163
 
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
164
+ ### Continuous `Interpolation`
294
165
 
295
166
  ```ts
296
- import { createSpring } from "anim-engine";
167
+ type Interpolation = {
168
+ resume: () => void
169
+ stop: () => void
170
+ setValue: (value: number) => void
171
+
172
+ value: number
173
+ velocity: number
174
+ status: "active" | "inactive"
175
+ }
176
+ ```
297
177
 
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
- });
178
+ **Options:**
308
179
 
309
- spring.setCurrentValue(0); // jump to start — spring chases back to 100
180
+ ```ts
181
+ type SpringOptions = {
182
+ to: () => number
183
+ stiffness?: DynamicValue // default 180
184
+ damping?: DynamicValue // default 12
185
+ mass?: DynamicValue // default 1
186
+ precision?: number // default 0.01
187
+ onUpdate?: (value: number, velocity: number) => void
188
+ onEnded?: () => void
189
+ ticker?: ExternalTicker
190
+ }
310
191
 
311
- // Dynamic target — mouse chase
312
- const targetX = { value: 0 };
313
- document.addEventListener("mousemove", (e) => {
314
- targetX.value = e.clientX;
315
- });
192
+ type SmoothDampOptions = {
193
+ to: () => number
194
+ smoothTimeMs: DynamicValue
195
+ maxSpeed?: DynamicValue
196
+ precision?: number // default 0.01
197
+ onUpdate?: (value: number, velocity: number) => void
198
+ onEnded?: () => void
199
+ ticker?: ExternalTicker
200
+ }
316
201
 
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
- });
202
+ type LerpOptions = {
203
+ to: () => number
204
+ smoothTimeMs: DynamicValue
205
+ precision?: number // default 0.01
206
+ onUpdate?: (value: number, velocity: number) => void
207
+ onEnded?: () => void
208
+ ticker?: ExternalTicker
209
+ }
323
210
  ```
324
211
 
325
- All parameters (`stiffness`, `damping`, `mass`, `to`) accept `number | (() => number)` — resolved every frame.
212
+ ### Common option patterns
326
213
 
327
- **Returns:** `Interpolation` `start()`, `stop()`, `kill()`, `setCurrentValue(value)`, `currentValue`, `velocity`, `status`. Starts at the `to()` value. Use `setCurrentValue(value)` to jump elsewhere.
214
+ | | `from` | `to` / target | `onUpdate` values | `durationMs` / `smoothTimeMs` |
215
+ |---|---|---|---|---|
216
+ | `createAnimation` (tween) | ✅ `DynamicValue` | ✅ `DynamicValue` | single `(value, velocity)` | ✅ `DynamicValue` |
217
+ | `createAnimation` (keyframes) | — (first keyframe) | — (last keyframe) | single `(value, velocity)` | — (sum of gaps) |
218
+ | `createTimeline` | — | — | multi `(values[], velocities[])` | — (computed) |
219
+ | `createSpring` | — (chases current) | ✅ `() => number` | single `(value, velocity)` | — (physics params) |
220
+ | `createSmoothDamp` | — (chases current) | ✅ `() => number` | single `(value, velocity)` | ✅ `DynamicValue` |
221
+ | `createLerp` | — (chases current) | ✅ `() => number` | single `(value, velocity)` | ✅ `DynamicValue` |
328
222
 
329
- #### createSmoothDamp
223
+ ### Shared options
330
224
 
331
225
  ```ts
332
- import { createSmoothDamp } from "anim-engine";
226
+ type DynamicValue = number | (() => number)
333
227
 
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
228
+ type ExternalTicker = {
229
+ add: (handler: TickHandler) => void
230
+ remove: (handler: TickHandler) => void
231
+ }
342
232
  ```
343
233
 
344
- Unity-style smooth damp with Taylor-series exponential approximation. No stiffness/damping/mass to tune — just `smoothTimeMs` (milliseconds to reach target).
234
+ ---
345
235
 
346
- **Returns:** `Interpolation`
236
+ ## Examples
347
237
 
348
- #### createLerp
238
+ ### Single tween
349
239
 
350
240
  ```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),
241
+ const anim = createAnimation({
242
+ from: 0, to: 100, durationMs: 2000, ease: "outElastic",
243
+ onUpdate: (v) => (sprite.x = v),
357
244
  });
358
-
359
- lerp.setCurrentValue(0); // jump to start — lerp chases back to 100
245
+ await anim.play();
360
246
  ```
361
247
 
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
248
+ ### Keyframes
367
249
 
368
250
  ```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)
251
+ createAnimation({
252
+ keyframes: [
253
+ { value: 0 },
254
+ { value: 50, gap: 300, ease: "outCubic" },
255
+ { value: 100, gap: 400 },
256
+ ],
257
+ onUpdate: (v) => (sprite.x = v),
258
+ }).play();
375
259
  ```
376
260
 
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.
261
+ ### Repeat & yoyo
382
262
 
383
263
  ```ts
384
- import { lerpOklab, hexToRgba } from "anim-engine";
264
+ const anim = createAnimation({
265
+ from: () => (forward ? 1 : 1.3),
266
+ to: () => (forward ? 1.3 : 1),
267
+ durationMs: 600, ease: "inOutSine",
268
+ onUpdate: (v) => sprite.scale.set(v),
269
+ });
385
270
 
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)
271
+ for (let i = 0; i < 6; i++) {
272
+ forward = !forward;
273
+ await anim.play();
274
+ }
275
+ ```
389
276
 
390
- const fromColor = hexToRgba("#ff6b6b");
391
- const toColor = hexToRgba("#4ecdc4");
277
+ ### Timeline
392
278
 
393
- createAnimation({
394
- from: 0,
395
- to: 1,
396
- durationMs: 2000,
397
- ease: "outCubic",
398
- onUpdate: (t) => {
399
- const [r, g, b, a] = lerpOklab(fromColor, toColor, t);
400
- sprite.setColor(r, g, b, a);
401
- },
279
+ ```ts
280
+ createTimeline([
281
+ { at: 0, animation: { keyframes: [{ value: 0 }, { value: 1, gap: 500 }] } },
282
+ { at: 0, animation: { keyframes: [{ value: -100 }, { value: 0, gap: 800, ease: "outBack" }] } },
283
+ ], {
284
+ onProgress: (p) => console.log(p),
285
+ onUpdate: (values, velocities) => { /* one entry per layer */ },
402
286
  });
403
287
  ```
404
288
 
405
- **With continuous primitives** — drive the blend factor via spring, damp, or lerp:
289
+ ### Spring
406
290
 
407
291
  ```ts
408
- import { createSpring, lerpOklab, hexToRgba } from "anim-engine";
409
-
410
- const fromColor = hexToRgba("#ff6b6b");
411
- const toColor = hexToRgba("#4ecdc4");
412
-
413
292
  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] = lerpOklab(fromColor, toColor, t);
420
- sprite.setColor(r, g, b, 1);
421
- },
293
+ to: () => mouseX,
294
+ stiffness: 180, damping: 12,
295
+ onUpdate: (v) => (sprite.x = v),
422
296
  });
423
297
  ```
424
298
 
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.
299
+ ### Smooth damp
434
300
 
435
301
  ```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",
302
+ createSmoothDamp({
303
+ to: () => 100, smoothTimeMs: 300,
445
304
  onUpdate: (v) => (sprite.x = v),
446
- }).play();
305
+ });
447
306
  ```
448
307
 
449
- **Custom game loop:** Call `getTicker().update(deltaMs)` from your own loop. Syncs to PixiJS ticker, ThreeJS `requestAnimationFrame`, or a fixed-step physics loop.
308
+ ### Lerp
450
309
 
451
310
  ```ts
452
- import { getTicker } from "anim-engine";
311
+ createLerp({
312
+ to: () => 100, smoothTimeMs: 300,
313
+ onUpdate: (v) => (sprite.x = v),
314
+ });
315
+ ```
453
316
 
454
- const animEngineTicker = getTicker();
317
+ ### Smooth clamp
455
318
 
456
- // Call once per frame from your game loop
457
- function gameLoop(deltaMs: number) {
458
- animEngineTicker.update(deltaMs);
459
- // ... physics, rendering
460
- }
319
+ ```ts
320
+ const clamp = createSmoothClamp(45);
321
+ clamp(1000); // ≈ 44.96 (asymptotic to 45)
461
322
  ```
462
323
 
463
- **Stop:** Call `getTicker().stop()` or `animEngineTicker.stop()` to clean up the rAF loop.
324
+ ---
464
325
 
465
326
  ## Easing
466
327
 
467
- 31 Penner easing functions plus custom cubic bezier:
328
+ 31 named presets plus custom cubic bezier:
468
329
 
469
330
  ```ts
470
- import { cubicBezier, EASE_NAMES } from "anim-engine";
331
+ type EaseName =
332
+ | "linear" | "inQuad" | "outQuad" | "inOutQuad"
333
+ | "inCubic" | "outCubic" | "inOutCubic"
334
+ | "inQuart" | "outQuart" | "inOutQuart"
335
+ | "inQuint" | "outQuint" | "inOutQuint"
336
+ | "inSine" | "outSine" | "inOutSine"
337
+ | "inExpo" | "outExpo" | "inOutExpo"
338
+ | "inCirc" | "outCirc" | "inOutCirc"
339
+ | "inBack" | "outBack" | "inOutBack"
340
+ | "inElastic" | "outElastic" | "inOutElastic"
341
+ | "inBounce" | "outBounce" | "inOutBounce"
342
+
343
+ type EaseFunction = (t: number) => number
344
+ ```
471
345
 
472
- const customEase = cubicBezier(0.25, 0.1, 0.25, 1); // custom easing function
346
+ Custom bezier:
473
347
 
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
- });
348
+ ```ts
349
+ const ease = cubicBezier(0.25, 0.1, 0.25, 1);
350
+ createAnimation({ from: 0, to: 100, durationMs: 1000, ease, ... });
482
351
  ```
483
352
 
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`.
353
+ ---
487
354
 
488
355
  ## Dynamic values
489
356
 
490
- The `DynamicValue` type (`number | (() => number)`) lets you provide values that are resolved at runtime. How often they're resolved depends on the primitive:
357
+ `DynamicValue` (`number | (() => number)`) is resolved at different frequencies depending on the primitive:
491
358
 
492
- ### Timed animations (per-play)
493
-
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:
359
+ - **Timed animations** — resolved once per `play()` call and cached for the run.
360
+ - **Continuous primitives** — resolved every frame (targets are live).
495
361
 
496
362
  ```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();
363
+ // Resolved at play time, cached for duration
364
+ createAnimation({
365
+ from: () => getX(),
366
+ durationMs: () => 500 / speed,
367
+ keyframes: [{ value: 0 }, { value: () => targetY, gap: 300 }],
368
+ }).play();
510
369
 
511
- // On re-play, values are re-resolved
512
- await anim.play();
370
+ // Resolved every frame
371
+ createSpring({ to: () => mouseX, ... });
513
372
  ```
514
373
 
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.
374
+ ---
516
375
 
517
- ### Continuous primitives (per-frame)
376
+ ## Color
518
377
 
519
- In `createSpring`, `createSmoothDamp`, and `createLerp`, dynamic values are resolved **every frame** — these primitives are designed to chase live targets:
378
+ Oklab (perceptually uniform) color interpolation via `lerpRgba`.
520
379
 
521
380
  ```ts
522
- const spring = createSpring({
523
- to: () => getMousePosition(), // re-read every frame
524
- stiffness: () => sliderValue, // dynamic stiffness
525
- damping: () => dampingValue,
381
+ const from = hexToRgba("#ff6b6b"); // [1, 0.42, 0.42, 1]
382
+ const to = hexToRgba("#4ecdc4");
383
+
384
+ createAnimation({
385
+ from: 0, to: 1, durationMs: 2000,
386
+ onUpdate: (t) => {
387
+ const [r, g, b, a] = lerpRgba(from, to, t);
388
+ sprite.setColor(r, g, b, a);
389
+ },
526
390
  });
527
391
  ```
528
392
 
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.
530
-
531
- ## Benchmarks
393
+ Accepts `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA` formats.
532
394
 
533
- Performance comparison against GSAP (vitest bench, Apple Silicon M-series, Node 24). All easing functions are matched between libraries.
395
+ ---
534
396
 
535
- ### vs GSAP internal mutation
397
+ ## Ticker
536
398
 
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).
399
+ Does **not** auto-start. Explicit control:
538
400
 
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 |
401
+ ```ts
402
+ import { getTicker } from "anim-engine";
551
403
 
552
- ### vs GSAP onUpdate (fair comparison)
404
+ // rAF mode
405
+ getTicker().start();
553
406
 
554
- gsap also supports `onUpdate` callbacks, which is equivalent to anim-engine's renderer-agnostic model. This is the apples-to-apples comparison.
407
+ // Custom game loop
408
+ const ticker = getTicker();
409
+ function loop(dt: number) {
410
+ ticker.update(dt);
411
+ }
412
+ ```
555
413
 
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 |
414
+ Stop with `getTicker().stop()`.
561
415
 
562
- Run locally: `npm run bench`
416
+ ---
563
417
 
564
418
  ## Game engine integration
565
419
 
566
420
  ### PixiJS
567
421
 
568
422
  ```ts
569
- import { Application, Sprite } from "pixi.js";
570
423
  import { createAnimation, getTicker } from "anim-engine";
424
+ const ticker = getTicker();
571
425
 
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);
426
+ app.ticker.add((delta) => ticker.update(delta.deltaMS));
577
427
 
578
- // Sync anim-engine ticker to PixiJS ticker
579
- const animEngineTicker = getTicker();
580
- app.ticker.add((delta) => {
581
- animEngineTicker.update(delta.deltaMS);
582
- });
583
-
584
- createAnimation({
585
- from: 0,
586
- to: 300,
587
- durationMs: 2000,
588
- ease: "outElastic",
428
+ createAnimation({ from: 0, to: 300, durationMs: 2000, ease: "outElastic",
589
429
  onUpdate: (x) => (sprite.x = x),
590
430
  }).play();
591
431
  ```
@@ -593,95 +433,39 @@ createAnimation({
593
433
  ### ThreeJS
594
434
 
595
435
  ```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
436
  const clock = new THREE.Clock();
437
+ const ticker = getTicker();
611
438
 
612
439
  function animate() {
613
440
  requestAnimationFrame(animate);
614
- animEngineTicker.update(clock.getDelta() * 1000);
441
+ ticker.update(clock.getDelta() * 1000);
615
442
  renderer.render(scene, camera);
616
443
  }
617
444
  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
445
  ```
627
446
 
628
- ### Custom game loop
447
+ ---
629
448
 
630
- ```ts
631
- import { getTicker, createSpring } from "anim-engine";
449
+ ## Benchmarks
632
450
 
633
- const animEngineTicker = getTicker();
451
+ vs GSAP (vitest bench, Apple Silicon M-series, Node 24). Matched easing functions.
634
452
 
635
- function gameLoop(timestamp: number) {
636
- const deltaMs = timestamp - lastTimestamp;
637
- lastTimestamp = timestamp;
453
+ | Benchmark | anim-engine | GSAP | Ratio |
454
+ |---|---|---|---|
455
+ | Single tween (cubic, 1000 frames) | 40,814 ops/s | 10,776 ops/s | 3.8× |
456
+ | Single tween (bezier, 1000 frames) | 21,449 ops/s | 12,637 ops/s | 1.7× |
457
+ | Keyframe (3 segments, 1000 frames) | 26,741 ops/s | 3,549 ops/s | 7.5× |
458
+ | 50 concurrent tweens (500 frames) | 892 ops/s | 438 ops/s | 2.0× |
459
+ | 200 concurrent tweens (500 frames) | 200 ops/s | 106 ops/s | 1.9× |
460
+ | 1000 concurrent tweens (500 frames) | 38 ops/s | 22 ops/s | 1.7× |
461
+ | 50-layer timeline (500 frames) | 710 ops/s | 404 ops/s | 1.8× |
462
+ | 50 tweens re-play (500 frames) | 558 ops/s | 128 ops/s | 4.4× |
638
463
 
639
- animEngineTicker.update(deltaMs);
640
- // ... physics, rendering, etc.
464
+ vs GSAP `onUpdate` (apples-to-apples): **3.0–6.2× faster**.
641
465
 
642
- requestAnimationFrame(gameLoop);
643
- }
644
- requestAnimationFrame(gameLoop);
645
- ```
466
+ Run locally: `npm run bench`
646
467
 
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
- | `lerpOklab(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` |
468
+ ---
685
469
 
686
470
  ## License
687
471