@zakkster/lite-keyframe
v1.0.0
Published
Zero-GC, high-performance multi-segment keyframe timelines. Animate UI and particle systems with typed-array backed state and O(1) cursor-advanced evaluation.
Downloads
69
Maintainers
Readme
@zakkster/lite-keyframe
🎞️ What is lite-keyframe?
@zakkster/lite-keyframe is a multi-segment easing curve. A regular easing function takes one value and bends it across [0, 1]. A keyframe timeline does the same thing — but across an arbitrary sequence of waypoints, with a different ease per segment.
The whole library is two classes built on a single shared evaluator:
- 🟢
Keyframe— one timeline per instance. Best for hero animations, scripted UI reveals, one-off complex sequences. - 🟣
KeyframePool— N timelines packed in an SoA layout. Best for mass animation: particle systems, grids, multi-element UIs.
Both produce a stable (t) => number reference that drops directly into anything that takes an easing function — lite-tween, GSAP, your own RAF loop. Zero allocations on the hot path. Zero dependencies. Under 2 KB min+gzip.
🧬 Where it fits
lite-keyframe is the composition layer of the zero-GC animation pipeline:
flowchart LR
A[lite-ease<br/><sub>raw math</sub>]:::raw --> B[lite-ease-lut<br/><sub>bake to LUT</sub>]:::lut
B --> C[lite-keyframe<br/><sub>compose segments</sub>]:::keyf
A --> C
C --> D[lite-tween<br/><sub>drive over time</sub>]:::tween
D --> E[your render loop]:::render
classDef raw fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e
classDef lut fill:#fef3c7,stroke:#d97706,color:#78350f
classDef keyf fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef tween fill:#ede9fe,stroke:#7c3aed,color:#4c1d95
classDef render fill:#f3f4f6,stroke:#6b7280,color:#1f2937Each library is independent. You can use lite-keyframe standalone with raw (t) => number callbacks — no framework lock-in.
🚀 Install
npm install @zakkster/lite-keyframe🕹️ Quick Start
Single timeline — Keyframe
import Keyframe from '@zakkster/lite-keyframe';
import { easeOutBounce, easeInQuad } from '@zakkster/lite-ease';
// Reusable easing bank shared by the timeline
const tl = new Keyframe(8).setBank([easeOutBounce, easeInQuad]);
// (time, value, easeIdx) — easeIdx -1 = linear, 0..127 = bank index
tl.addKey(0.0, 0, -1); // start
tl.addKey(0.4, 100, 0); // bounce in
tl.addKey(0.7, 60, 1); // quad-ease back down
tl.addKey(1.0, 200, -1); // linear settle
// tl.eval is a stable arrow ref — drop it anywhere a (t) => number is wanted
mgr.to(player, 'x', 0, 200, 5.0, { ease: tl.eval });
// Or use it directly
console.log(tl.eval(0.5)); // → 80 (interpolated through segment 1)Mass animation — KeyframePool
import { KeyframePool } from '@zakkster/lite-keyframe';
import { easeOutCubic } from '@zakkster/lite-ease';
// 500 independent timelines, max 4 keys each (~21 KB total)
const pool = new KeyframePool(500, 4).setBank([easeOutCubic]);
// Build different timelines per particle
for (let i = 0; i < 500; i++) {
pool.addKey(i, 0, spawnY[i], -1);
pool.addKey(i, 0.3, peakY[i], 0); // ease into peak
pool.addKey(i, 1.0, groundY[i], -1);
}
// In your update loop — pure typed-array reads, zero allocation
function update(t) {
for (let i = 0; i < 500; i++) {
particles[i].y = pool.eval(i, t);
}
}🧠 Why this exists
Most animation libraries collapse "the curve" into a single easing function. That works for simple from → to tweens but falls apart the moment you need a path that bends multiple times: bounce → settle → drift → snap. The usual workarounds — chained tweens, nested timelines, after-completion callbacks — all allocate, all schedule, all add jank.
A keyframe timeline solves this by treating the whole path as one object you can evaluate at any normalized time t. The curve becomes a pure function: t → value. Combine that with the data-oriented constraint that hot-path code must allocate zero memory and you get this library.
The design ladder:
- Drop-in
(t) => number. Anywhere you'd passeaseOutBounce, you can passtl.eval. No framework knows or cares. The integration contract is one type signature. - Cursor-tracked O(1) eval. Steady-state forward play (the 90%+ case) doesn't search for the active segment — it stays put. The cold path is reserved for scrubbing and rewinds.
- SoA pool for mass animation. When you need 500 timelines, allocating 500 instances is pointless overhead.
KeyframePoolpacks everything into shared typed arrays — same eval primitive, same API, vastly less memory pressure. - Validate at setup, not at frame 60. Bad data (NaN time, missing bank, out-of-range index) throws when you insert it, not when something silently returns garbage three minutes into a session.
🔥 Hot-path architecture
The whole library exists to make eval() cheap. Inside the evaluator, a 4-branch cascade picks the cheapest possible answer:
flowchart TD
A[eval(t)]:::start --> B{n < 1?<br/>n < 2?}
B -->|yes| C[return early]:::trivial
B -->|no| D{NaN or<br/>out of range?}
D -->|yes| E[clamp to first/last]:::trivial
D -->|no| F{cursor's<br/>current segment<br/>still valid?}
F -->|yes ~90%| G[STAY<br/><sub>0 reads</sub>]:::stay
F -->|no| H{advance 1?}
H -->|yes| I[c++ <br/><sub>per-key boundary</sub>]:::adv
H -->|no| J{advance 2?}
J -->|yes| K[c += 2<br/><sub>frame skip</sub>]:::adv
J -->|no| L[COLD PATH<br/>linear or binary scan]:::cold
G --> M[interpolate t0 → t1]:::out
I --> M
K --> M
L --> M
classDef start fill:#e0f2fe,stroke:#0284c7
classDef trivial fill:#f3f4f6,stroke:#6b7280
classDef stay fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef adv fill:#fef3c7,stroke:#d97706,color:#78350f
classDef cold fill:#fecaca,stroke:#dc2626,color:#7f1d1d
classDef out fill:#ede9fe,stroke:#7c3aed,color:#4c1d95The cold-path scan is itself adaptive: linear scan for n ≤ 16 (faster than binary search at small n thanks to the branch predictor and cache prefetch), binary search above that.
🏗️ SoA memory layout (KeyframePool)
KeyframePool is a Structure-of-Arrays packing of N timelines. Each per-key field lives in its own typed array, contiguous across all timelines:
┌─ row 0 ─┬─ row 1 ─┬─ row 2 ─┬─ ... ─┐
times[] = │ t₀ t₁ … │ t₀ t₁ … │ t₀ t₁ … │ … │ Float32Array
values[] = │ v₀ v₁ … │ v₀ v₁ … │ v₀ v₁ … │ … │ Float32Array
easings[] = │ e₀ e₁ … │ e₀ e₁ … │ e₀ e₁ … │ … │ Int8Array
└────────┴─────────┴─────────┴───────┘
stride stride stride
cursors[] = [ c₀, c₁, c₂, … ] Int32Array (one per row)
counts[] = [ n₀, n₁, n₂, … ] Int32Array (one per row)Why this layout: when you're evaluating row i, the keys for row i sit in one contiguous cache line. The cursor and count for row i sit at cursors[i] and counts[i] — also contiguous in their own arrays. No chasing pointers across heap-allocated instances.
Memory cost: capacity × stride × 9 bytes for keyframe data + capacity × 8 bytes for cursors and counts. 500 timelines × 4 keys ≈ 21 KB total.
📊 Comparison
| | lite-keyframe | GSAP Timeline | Theatre.js | raw chained tweens |
|---|---|---|---|---|
| Bundle size | <2 KB | ~30 KB (gsap core) | ~250 KB | depends |
| Hot-path allocations | 0 | several per frame | many | several per frame |
| Mass animation API | SoA pool built in | manual | not the use case | manual |
| Evaluator type | pure (t) => number | Timeline object | Studio-bound | controller object |
| Multi-segment ease | 1 timeline | nested tweens | yes (visual) | chain + callbacks |
| Setup-time validation | strict | lenient | strict | none |
| Framework lock-in | none | GSAP runtime | Theatre.js studio | tween library's own |
lite-keyframe is not a replacement for GSAP or Theatre — those are full animation systems with timelines, easings, callbacks, plugins, scrubbing UIs. This is the kernel that does one thing well: turn a sequence of (time, value, ease) keys into a fast pure function.
⚙️ API
class Keyframe
new Keyframe(maxKeys?: number) // default 16| Method / Property | Description |
|---|---|
| .setBank(bank) | Set the easing bank (array of (t) => number). Max 127 fns. Returns this. |
| .addKey(time, value, easeIdx?) | Insertion-sorted key add. Returns slot index, or -1 if full. |
| .setKey(idx, time, value, easeIdx?) | Direct slot write — caller owns the sort invariant. |
| .eval(t) | Stable arrow ref. Pre-bound for use as a callback. |
| .clear() | Reset to zero keys; buffers retained. |
| .destroy() | Null out typed arrays. Safe for GC. |
| .keyCount | Active key count (getter). |
| .maxKeys | Capacity (getter). |
class KeyframePool
new KeyframePool(capacity: number, maxKeysPerTimeline?: number) // default stride 8| Method / Property | Description |
|---|---|
| .setBank(bank) | Shared easing bank for the entire pool. Returns this. |
| .addKey(rowIdx, time, value, easeIdx?) | Add to row rowIdx. Returns slot index, or -1 if full. |
| .setKey(rowIdx, slotIdx, time, value, easeIdx?) | Direct write into a row's slot. |
| .eval(rowIdx, t) | Evaluate timeline at row rowIdx. Hot path — no rowIdx bounds check. |
| .clear(rowIdx) | Reset one row. |
| .clearAll() | Reset every row. |
| .keyCount(rowIdx) | Active keys in one row. |
| .destroy() | Null out all typed arrays. |
| .capacity | Number of rows (getter). |
| .stride | Max keys per row (getter). |
Validation rules
The library throws loudly at setup so you don't debug silent garbage at frame 60:
| Method | Throws on |
|---|---|
| setBank | non-array bank, or bank.length > 127 |
| addKey | NaN / Infinity time, non-int easeIdx, easeIdx outside [-1, 127], or easeIdx >= 0 with no/insufficient bank |
| setKey | NaN / Infinity time |
| KeyframePool.{addKey, setKey, clear, keyCount} | rowIdx outside [0, capacity) |
KeyframePool.eval() is the only method that does not bounds-check rowIdx — it's the hot path, and validation belongs to setup.
⚡ Performance characteristics
| Operation | Cost |
|---|---|
| eval() steady-state forward play | ~3 typed-array reads + 1 multiply-add |
| eval() per-key advance | ~5 typed-array reads + 1 multiply-add |
| eval() cold path (rewind, scrub) | linear scan O(n) for n≤16, binary search O(log n) above |
| addKey() | O(n) worst case (insertion sort), O(1) appends |
| setKey() | O(1) |
| eval reference identity | stable for the lifetime of the instance |
| Hot-path allocations | zero |
The cursor lives in a length-1 Int32Array (not a number field) so the access site inside the evaluator is monomorphic across Keyframe and KeyframePool — both classes hit the same typed-array indexing pattern, keeping V8's inline cache happy.
📦 TypeScript
Full TypeScript declarations included in Keyframe.d.ts. All validation behaviors are surfaced in JSDoc so IntelliSense shows you exactly what each method throws.
import Keyframe, { KeyframePool, EasingFunction } from '@zakkster/lite-keyframe';
const myEase: EasingFunction = (t) => t * t;📚 LLM-friendly documentation
See llms.txt for a structured reference designed for AI coding assistants. Covers public surface, validation rules, integration patterns, and common pitfalls in one parseable document.
🧩 Pairs well with
@zakkster/lite-ease— raw easing functions to populate the bank@zakkster/lite-ease-lut— bake any easing into a LUT for cheaper repeated calls@zakkster/lite-tween— the manager that drivestl.evalover time
License
MIT © Zahary Shinikchiev
