@zakkster/lite-cubic-bezier
v1.0.0
Published
High-performance, zero-GC CSS cubic-bezier runtime. Features a DoD-friendly coefficient compiler and Newton-Raphson solver with bisection fallback.
Maintainers
Readme
@zakkster/lite-cubic-bezier
📐 What is lite-cubic-bezier?
@zakkster/lite-cubic-bezier is the CSS-compatible cubic-bezier runtime for the Lite ecosystem. You hand it the four control numbers from cubic-bezier(x1, y1, x2, y2) and you get back a stable (t) => number ready to drive any animation.
The library exposes two APIs that share the same math kernel:
- 🟢 Closure API —
cubicBezier(x1, y1, x2, y2)returns a(t) => number. Drop-in for anything that takes an easing function. The 90% case. - 🔵 DoD API —
compileBezier()produces a flat coefficient struct,evalBezier(t, c)is a stateless evaluator. For tight inner loops, ECS systems, and anywhere you want to control allocations at the call site.
Both paths run a Newton-Raphson solver with bisection fallback — the same algorithm shape browsers use for cubic-bezier() in CSS animations. Zero allocations on the hot path. Zero dependencies. Sub-1KB min+gzip.
🧬 Where it fits
lite-cubic-bezier is the parametric curve layer of the zero-GC animation pipeline — the bridge between a CSS-style declarative curve specification and a callable easing function:
flowchart LR
A[Designer / CSS<br/><sub>cubic-bezier x1,y1,x2,y2</sub>]:::input --> B[lite-cubic-bezier<br/><sub>compile + eval</sub>]:::core
A2[lite-ease<br/><sub>Penner functions</sub>]:::sibling --> C[t => number]:::out
B --> C
C --> D[lite-ease-lut<br/><sub>bake to LUT</sub>]:::lut
C --> E[lite-keyframe<br/><sub>compose segments</sub>]:::keyf
C --> F[lite-tween<br/><sub>drive over time</sub>]:::tween
E --> F
F --> G[your render loop]:::render
classDef input fill:#fef3c7,stroke:#d97706,color:#78350f
classDef sibling fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e
classDef core fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef out fill:#f0fdf4,stroke:#16a34a,color:#14532d
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:#1f2937Every library in the chain is independent. You can use lite-cubic-bezier standalone with raw (t) => number callbacks — no framework lock-in.
🚀 Install
npm install @zakkster/lite-cubic-bezier🕹️ Quick Start
Closure API — the common case
import { cubicBezier } from '@zakkster/lite-cubic-bezier';
// CSS "ease" — cubic-bezier(0.25, 0.1, 0.25, 1.0)
const ease = cubicBezier(0.25, 0.1, 0.25, 1.0);
ease(0.0); // → 0
ease(0.5); // → 0.802
ease(1.0); // → 1
// Drop it into anything that takes an easing function
mgr.to(player, 'x', 0, 200, 5.0, { ease });
// Linear curves return a shared cached identity — zero closure churn
const linearA = cubicBezier(0, 0, 1, 1);
const linearB = cubicBezier(0.5, 0.5, 0.5, 0.5);
linearA === linearB; // → trueDoD API — manual struct control
import { compileBezier, evalBezier } from '@zakkster/lite-cubic-bezier';
// Pre-compile once at setup
const c = compileBezier(0.42, 0, 0.58, 1.0); // CSS "ease-in-out"
// Re-use the struct across millions of evaluations — zero allocations
function tick(progress) {
const eased = evalBezier(progress, c);
// ... write to your typed-array buffer, particle system, whatever
}
// Or store many curves in an array of structs (perfectly stable hidden class)
const curves = [
compileBezier(0.25, 0.1, 0.25, 1.0),
compileBezier(0.42, 0, 0.58, 1.0),
compileBezier(0.5, -0.5, 0.5, 1.5), // back-out with overshoot
];
const value = evalBezier(t, curves[curveIdx]);🧠 Why this exists
CSS specifies easings declaratively: cubic-bezier(0.25, 0.1, 0.25, 1.0) means "use this exact parametric curve." But evaluating it at runtime is not free — the function doesn't directly give you y(t). The curve is parameterized by an internal variable u, so you have to:
- Solve
x(u) = tforu(a cubic root-finding problem) - Then compute
y(u)using thatu
Most implementations close over the four input numbers and allocate fresh objects per call, or compute coefficients on every evaluation. At 60fps with hundreds of timelines, that's a measurable hit on the allocator and the GC.
lite-cubic-bezier separates the two halves of the work:
- Setup-time (once per curve): expand the four control numbers into eight cached polynomial coefficients (
ax, bx, cx, ay, by, cyplus precomputed derivative coefficientsax3, bx2). One small struct, allocated once. - Hot path (per frame, per element): pure scalar math against that struct. Newton-Raphson converges in 2–3 iterations on every CSS-spec curve. Zero allocations, zero closures created, zero indirection.
The DoD API exposes the struct directly so you can store curves in a typed-array-adjacent layout (one struct per ECS component, one per particle, one per timeline segment) and pass them to evalBezier() without any wrapping function.
🔥 Algorithm
The evaluator runs a two-stage solver: Newton-Raphson first (fast, quadratic convergence in the typical case) with a bisection fallback for the small set of pathological curves where Newton can stall. Both stages share the same compiled coefficients.
flowchart TD
A[evalBezier t, c]:::start --> B{t ≤ 0<br/>or t ≥ 1?}
B -->|yes| C[return 0 / 1]:::trivial
B -->|no| D[Newton-Raphson<br/><sub>up to 8 iterations</sub>]:::newton
D --> E{|x_u − t|<br/>< 1e-7?}
E -->|yes ~95%| F[return y_u]:::out
E -->|no| G{|x' u|<br/>< 1e-6?}
G -->|yes| H[break Newton<br/><sub>derivative collapsed</sub>]:::brk
G -->|no| I[u ← u − err/dx<br/>clamp to 0,1]:::iter
I --> D
H --> J[Bisection fallback<br/><sub>up to 32 iterations</sub><br/><sub>retains post-Newton u</sub>]:::bisect
D -->|exhausted 8 iters| J
J --> K{|x_u − t|<br/>< 1e-6?}
K -->|yes| F
K -->|no| L[halve bracket]:::iter
L --> J
classDef start fill:#e0f2fe,stroke:#0284c7
classDef trivial fill:#f3f4f6,stroke:#6b7280
classDef newton fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef iter fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef brk fill:#fef3c7,stroke:#d97706,color:#78350f
classDef bisect fill:#fecaca,stroke:#dc2626,color:#7f1d1d
classDef out fill:#ede9fe,stroke:#7c3aed,color:#4c1d95Key invariants:
- The bisection bracket
[t0, t1]always contains the true solution becausex(u)is monotonic on[0, 1]for anyx1, x2 ∈ [0, 1](per the CSS spec). - Newton's
uis clamped into[0, 1]after every step to prevent runaway divergence on near-zero derivatives. - Convergence is checked at the top of each Newton iteration against fresh
xu, so the early-exit branch is the truth, not a stale comparison. - Bisection retains the post-Newton
uinstead of resetting — even when Newton "fails," it gives bisection a much better starting point thant.
🏗️ Coefficient struct layout
compileBezier() returns a flat object with this exact key order:
{
ax, bx, cx, // cubic, quadratic, linear coefficients of x(u)
ay, by, cy, // cubic, quadratic, linear coefficients of y(u)
ax3, bx2, // precomputed derivative coefficients (3*ax, 2*bx)
}Why this order: V8 assigns a hidden class based on the order properties are added. By always writing in the same sequence, every coefficient struct in your program shares one hidden class — which means evalBezier's property loads (c.ax, c.bx, …) hit the same inline cache slot every time. Don't add fields conditionally. Don't reorder. Don't delete. The struct is treated as immutable by the evaluator, but the V8-visible "shape" is the actual contract.
The struct is intentionally a plain object, not a Float64Array, because:
- Property loads on a stable hidden class are as fast as typed-array indexing in modern V8.
- A plain object lets the evaluator destructure inline if hot enough for the JIT.
- It keeps the API trivially serializable and inspectable in DevTools.
📊 Comparison
| | lite-cubic-bezier | bezier-easing | popmotion's cubicBezier | hand-rolled inline | |---|---|---|---|---| | Bundle (min+gzip) | <1 KB | ~1.5 KB | bundled with framework | depends | | Hot-path allocations | 0 | depends on impl | depends | usually 0 | | DoD struct API | yes | no | no | manual | | Closure API | yes | yes | yes | manual | | Newton + Bisection | yes | yes | varies | usually no | | Shared linear identity | yes | no | no | no | | TypeScript types | yes (full) | yes | yes | n/a | | ECS / SoA friendly | yes | no | no | yes | | Framework lock-in | none | none | popmotion | none |
lite-cubic-bezier is not trying to replace browser-native CSS easing or full animation libraries. It's the kernel that does one thing well: turn four control numbers into a deterministic, allocation-free (t) => number.
⚙️ API
Closure API
cubicBezier(x1, y1, x2, y2): EasingFunction
Returns a stable (t) => number closure. Coefficients are computed once at construction.
| Param | Type | Description |
|---|---|---|
| x1 | number | Control point 1 X. Per CSS spec, must be in [0, 1]. |
| y1 | number | Control point 1 Y. May be outside [0, 1] for "back" easings. |
| x2 | number | Control point 2 X. Per CSS spec, must be in [0, 1]. |
| y2 | number | Control point 2 Y. May be outside [0, 1] for overshoot. |
When x1 === y1 && x2 === y2 (mathematically the identity easing), the function returns a shared cached (t) => t instead of a new closure — useful when many call sites construct linear curves.
DoD API
compileBezier(x1, y1, x2, y2): BezierCoefficients
Pre-computes polynomial coefficients into a flat struct with stable hidden class. Call once per curve at setup time.
evalBezier(t: number, c: BezierCoefficients): number
Stateless evaluator. Zero allocations. Requires a struct produced by compileBezier().
| Param | Type | Description |
|---|---|---|
| t | number | Timeline progression. Values outside [0, 1] clamp to the nearest boundary. NaN propagates. |
| c | BezierCoefficients | The compiled coefficient struct. Must come from compileBezier() — passing {x1,y1,x2,y2} will return NaN. |
Types
export type EasingFunction = (t: number) => number;
export interface BezierCoefficients {
ax: number; bx: number; cx: number;
ay: number; by: number; cy: number;
ax3: number; bx2: number;
}⚡ Performance characteristics
| Operation | Cost |
|---|---|
| evalBezier() typical CSS curve | 2–3 Newton iterations, ~10 multiplications |
| evalBezier() Newton-converged early exit | 1 polynomial evaluation, ~5 multiplications |
| evalBezier() boundary case (t ≤ 0 or t ≥ 1) | 1 comparison |
| evalBezier() pathological curve, full bisection | up to 32 iterations |
| compileBezier() | 8 multiplications, 1 small allocation (setup, not hot path) |
| cubicBezier() linear fast-path | 0 allocations (returns shared IDENTITY) |
| Hot-path allocations | zero |
| Hidden class instances | one (shared across all compiled structs) |
🛡️ Validation
This library trusts its inputs. There is no runtime validation — bad data produces predictable bad output, not exceptions:
| Input | Behavior |
|---|---|
| t < 0 or t > 1 | Clamps to 0 / 1 |
| t === NaN | Output is NaN (propagates through Newton + Bisection) |
| x1 or x2 outside [0, 1] | x(u) may be non-monotonic; solver may return imprecise results |
| c not from compileBezier() | evalBezier() returns NaN |
This is deliberate: validation belongs at the boundary of your application, not at the bottom of every animation tick. Validate once when the user (or the CSS parser, or the keyframe loader) hands you the four control numbers.
📦 TypeScript
Full TypeScript declarations included in CubicBezier.d.ts. The BezierCoefficients interface is exported so you can store, pass, and serialize compiled structs with full type safety.
import { cubicBezier, compileBezier, evalBezier } from '@zakkster/lite-cubic-bezier';
import type { EasingFunction, BezierCoefficients } from '@zakkster/lite-cubic-bezier';
const ease: EasingFunction = cubicBezier(0.25, 0.1, 0.25, 1.0);
const coeffs: BezierCoefficients = compileBezier(0.42, 0, 0.58, 1.0);📚 LLM-friendly documentation
See llms.txt for a structured reference designed for AI coding assistants. Covers the public surface, the algorithm, integration patterns, and common pitfalls in one parseable document.
🧩 Pairs well with
@zakkster/lite-ease— 30 Penner easing functions as pure(t) => number. Sibling layer to this one.@zakkster/lite-ease-lut— bake any easing (including acubicBezier) into a LUT for cheaper repeated calls.@zakkster/lite-keyframe— compose multiple eases into a multi-segment timeline.
License
MIT © Zahary Shinikchiev
