Skip to content

Math, color & animation

insomni ships a small, dependency-free math and animation toolkit used throughout the renderer and exported from the package root. Everything is tree-shakable free functions and tiny stateful objects — import only what you use.

Preview of the Color ramps demo
Color ramps The same two endpoints, blended in sRGB with lerpColor and in OKLab with lerpColorOklab.

The easing curves below drive the tween and transition primitives covered later on this page.

Preview of the Easing & springs demo
Easing & springs Three dots chase one moving target — smoothDamp glides, a spring overshoots, linear snaps.

Vec2 is { x: number, y: number }. The helpers are plain functions that return new vectors (no mutation).

| Function | Signature | Result | | ----------- | ------------------ | ------------------------------------- | | vec2 | (x, y) => Vec2 | Construct a vector. | | add | (a, b) => Vec2 | a + b. | | sub | (a, b) => Vec2 | a - b. | | scale | (v, s) => Vec2 | v * s. | | length | (v) => number | Euclidean magnitude. | | normalize | (v) => Vec2 | Unit vector ({0,0} if zero-length). | | dot | (a, b) => number | Dot product. |

import { vec2, add, scale, normalize, length } from "insomni";
const dir = normalize(sub(target, origin));
const step = add(origin, scale(dir, length(dir) * 0.5));

Mat3 is a column-major 3×3 affine matrix, a readonly 9-tuple. The renderer uses it for view-projection; you’ll mostly need it for custom transforms.

| Function | Description | | ----------------------------- | --------------------------------------------------------- | | IDENTITY | The identity matrix constant. | | translation(tx, ty) | Translation matrix. | | scaling(sx, sy = sx) | Scale matrix (uniform if sy omitted). | | rotation(radians) | Rotation about the origin. | | scalingAt(sx, sy, cx, cy) | Scale around an arbitrary center. | | rotationAt(radians, cx, cy) | Rotate around an arbitrary center. | | multiply(a, b) | a · b — applies b first, then a. | | invert(m) | Inverse, or null if singular. | | transformPoint(m, x, y) | Apply the matrix to a point → Vec2. | | projection(width, height) | Orthographic pixel → NDC: (0,0)→(-1,1), (w,h)→(1,-1). |

import { multiply, translation, rotationAt, transformPoint } from "insomni";
const m = multiply(translation(100, 50), rotationAt(Math.PI / 4, cx, cy));
const p = transformPoint(m, x, y);

| Function | Description | | ------------------------------ | ------------------------------------------------------------------------------------------ | | cubicEval(p0, p1, p2, p3, t) | Evaluate a cubic Bézier at t (not clamped) → Vec2. | | cubicAABB(p0, p1, p2, p3) | Exact axis-aligned bounding box over t ∈ [0, 1]AABB ({ minX, minY, maxX, maxY }). |

cubicAABB solves the derivative roots per axis, so it’s the tight box, not the control-point hull.

Color is { r, g, b, a } with channels in [0, 1].

| Function | Description | | ------------------------ | -------------------------------------------------------------- | | rgba(r, g, b, a = 1) | From channels. | | hex(value) | 24-bit hex int, e.g. hex(0xff6600). Alpha is 1. | | cssHex(value) | CSS string, e.g. cssHex("#ff6600") or cssHex("#ff660080"). | | hsl(h, s, l, a = 1) | HSL → RGB. All inputs [0, 1]; hue wraps. | | hsv(h, s, v, a = 1) | HSV → RGB. All inputs [0, 1]; hue wraps. | | hslToRgb / hslToRgba | Aliases of hsl. |

| Function | Description | | ------------------------- | --------------------------------------------- | | lerpColor(a, b, t) | Channel-wise linear interpolation. | | mixColor | Alias of lerpColor (shader mix() naming). | | withAlpha(color, alpha) | Copy with a replaced alpha. | | fade(color, factor) | Multiply the existing alpha. | | lighten(color, t) | Mix toward WHITE by t. | | darken(color, t) | Mix toward BLACK by t. |

Constants: BLACK, WHITE, TRANSPARENT.

import { cssHex, withAlpha, lighten, BLACK } from "insomni";
const accent = cssHex("#3b82f6");
const ghost = withAlpha(accent, 0.15);
const hover = lighten(accent, 0.2);

sRGB ↔ OKLab / OKLCH conversions plus space-aware interpolation. OKLab is the modern default (CSS Color 4 color-mix uses it).

| Function | Description | | ----------------------------- | -------------------------------------------------------- | | srgbToOklab(color) | sRGB Color{ L, a, b }. | | oklabToSrgb(lab, alpha = 1) | OKLab → sRGB Color (gamut-clipped). | | oklabToOklch(lab) | OKLab → { L, C, h } (hue in turns [0, 1)). | | oklchToOklab(lch) | OKLCH → OKLab. | | lerpInSpace(a, b, t, space) | Interpolate two colors in a BlendSpace. | | lerpColorOklab(a, b, t) | Shorthand for lerpInSpace(..., "oklab") (recommended). | | lerpColorRgb(a, b, t) | Shorthand for the naive sRGB lerp. |

BlendSpace is "srgb" | "linear-rgb" | "oklab" | "oklch" | "hsl". oklch preserves hue along the path (great for staying “in family”); oklab is the perceptually-uniform default.

import { lerpInSpace, cssHex } from "insomni";
const ramp = (t: number) => lerpInSpace(cssHex("#001"), cssHex("#fb0"), t, "oklab");

WCAG 2.1 contrast math and a lightness search that nudges a color to meet a threshold against a fixed background.

| Function | Description | | ---------------------------------------------- | -------------------------------------------------------------------------- | | relativeLuminance(color) | WCAG relative luminance. | | contrastRatio(a, b) | Symmetric ratio in [1, 21]. | | wcagMinContrast(opts?) | Required ratio for WcagTextOptions. | | meetsContrast(fg, bg, opts?) | Whether fg on bg clears the threshold. | | findAccessibleColor(target, fixed, minRatio) | Nearest shade of target meeting minRatio (preserves hue + saturation). | | colorAtContrast(target, fixed, ratio) | Nearest shade landing at ratio — equalizes label weight. |

WcagTextOptions is { fontSizePx?, bold?, level? }. level is "AA" / "AAA" (WcagLevel) with the large-text exception, or a raw number for an absolute ratio.

import { findAccessibleColor, cssHex } from "insomni";
const safe = findAccessibleColor(cssHex("#9aa"), cssHex("#fff"), 4.5);

APCA (the perceptual algorithm proposed for WCAG 3) returns a signed lightness contrast Lc (roughly [-108, +106]): positive for dark-on-light, negative for light-on-dark.

| Function | Description | | ------------------------------------------ | ----------------------------------------------------------- | --- | -------------------------------- | | apcaContrast(fg, bg) | Signed Lc. 0 for pairs too close to read. | | apcaPolarity(fg, bg) | "normal" (dark-on-light) or "reverse" (ApcaPolarity). | | apcaFontLookup(fontSizePx, weight = 400) | Minimum | Lc | recommended for that text. | | findApcaColor(target, bg, minLc) | Nearest shade of target reaching minLc against bg. | | colorAtApca(target, bg, lc) | Nearest shade landing at | Lc | — equalizes perceptual weight. |

import { apcaContrast, findApcaColor, cssHex } from "insomni";
const lc = apcaContrast(cssHex("#333"), cssHex("#fff")); // ~ +90
const label = findApcaColor(cssHex("#888"), cssHex("#111"), 75);

| Symbol | Description | | --------------------------------- | --------------------------------------------- | | TAU, PI, HALF_PI | , π, π/2. | | DEG_TO_RAD, RAD_TO_DEG | Conversion constants. | | clamp(v, min, max) | Clamp into a range. | | clamp01(v) | Clamp into [0, 1]. | | lerp(a, b, t) | Linear interpolation. | | inverseLerp(a, b, value) | Where value sits between a and b. | | remap(v, a0, a1, b0, b1) | Map from one range to another (unclamped). | | smoothstep(edge0, edge1, v) | Hermite smoothstep (clamped). | | smootherstep(edge0, edge1, v) | Perlin 5th-order smoothstep. | | degToRad(deg) / radToDeg(rad) | Angle conversion. | | wrap(value, modulus) | Wrap into [0, modulus) (handles negatives). | | sign(value) | -1 / 0 / 1. | | approxEqual(a, b, eps = 1e-6) | Tolerance compare. |

Seeded PRNGs and sampling helpers. Each PRNG is a factory (seed) => Rng, where Rng is () => number returning a uniform [0, 1) float. Distribution helpers take the Rng as their first argument — one stream feeds many distributions. (Deterministic and fast; not cryptographic.)

| Generator | Notes | | ---------------------- | ---------------------------------------------- | | mulberry32(seed) | Tiny, fast, period 2³². | | splitmix32(seed) | Avalanches small seeds well; good for seeding. | | xorshift32(seed) | Minimal, fastest. | | sfc32(a, b?, c?, d?) | 128-bit state, high quality. |

| Distribution | Description | | ----------------------------------- | --------------------------------------------- | | uniform(rng, min, max) | Float in [min, max). | | uniformInt(rng, min, max) | Integer in [min, max]. | | gaussian(rng, mean = 0, std = 1) | Normal (Box–Muller). | | exponential(rng, lambda = 1) | Exponential. | | logNormal(rng, mu = 0, sigma = 1) | Log-normal. | | bates(rng, n = 1) | Mean of n uniforms. | | triangular(rng, min, max, mode) | Triangular distribution. | | pick(rng, arr) | One element uniformly. | | shuffle(rng, arr) | Fisher–Yates; returns a new array. | | sample(rng, arr, count) | count distinct elements. | | weighted(rng, arr, weights) | Pick by weight. | | onUnitCircle(rng) | Point on the unit circle → [x, y]. | | inUnitDisk(rng) | Point inside the unit disk (uniform density). |

import { mulberry32, gaussian, pick } from "insomni";
const rng = mulberry32(42);
const jitter = gaussian(rng, 0, 0.5);
const color = pick(rng, palette);

createInvalidator(initialDirty = true) is a dirty-tracking primitive for on-demand rendering: check dirty before a render and clear() after.

| Member | Description | | --------------------------- | ------------------------------------------------------- | | dirty | True if anything invalidated since the last clear(). | | invalidate() | Mark dirty. | | onInvalidate(cb) | Subscribe to invalidation edges (e.g. wake a rAF loop). | | clear() | Clear the flag. | | track(source) | Auto-invalidate on source.onChange. | | trackElement(el, events?) | Auto-invalidate on DOM events (pointer/wheel/keydown). | | dispose() | Release all tracked subscriptions. |

import { createInvalidator } from "insomni";
const inv = createInvalidator();
inv.track(viewport); // re-render on camera change
inv.trackElement(canvas); // ...and on input
function loop() {
if (inv.dirty) {
inv.clear();
renderer.render(layers);
}
requestAnimationFrame(loop);
}

animateToward(opts) is a minimal “ease toward a target” numeric, frame-rate independent (1 - exp(-speed·dt)). Pair it with an Invalidator to keep frames flowing until it settles.

AnimateTowardOptions: initial (default 0), speed (default 12), epsilon (default 0.002), invalidator. The returned AnimatedValue exposes value, target, active, setTarget(value, immediate?), and step(dt).

import { animateToward } from "insomni";
const opacity = animateToward({ speed: 8, invalidator: inv });
opacity.setTarget(hovered ? 1 : 0);
// per frame
opacity.step(dt);
draw(opacity.value);

Each is a tiny stateful object with a step(dt) method that returns the new value; compose by running one per scalar dimension. Common vocabulary: value, target, settled.

| Factory | Model | | ----------------------------- | ---------------------------------------------------------------------------- | | tween(opts) | Duration-driven interpolation with an easing; retargetable. | | spring(opts) | Physical m·ẍ + c·ẋ + k(x-target) spring (semi-implicit Euler, substepped). | | springFromDuration(spec) | Designer-friendly { duration, bounce }{ stiffness, damping, mass }. | | smoothDamp(opts) | Critically-damped second-order filter (Unity SmoothDamp). | | decay(opts) | Pure exponential velocity decay (fling / inertial scroll). | | transition(opts) | Fixed-duration, generic-T motion; also a ReadableSignal. | | tweenTo(opts) | Fire-and-forget tween that owns its own rAF clock. | | createVelocityTracker(opts) | 2D pointer velocity estimator for fling handoff. |

The workhorse behind bindViewport. One knob — smoothTime (default 0.1, 0 = instant) — approximates time to converge; built-in velocity makes it good for tracking a moving target and absorbing fling impulses.

import { smoothDamp } from "insomni";
const sd = smoothDamp({ smoothTime: 0.15, initialValue: 0 });
sd.setTarget(100);
sd.step(dt); // returns the new value
sd.setVelocity(500); // inject a fling impulse

SmoothDamp members: value, velocity, target, settled, smoothTime, maxSpeed, step(dt), setTarget, setValue, setVelocity, reset.

tween({ from, to, duration, easing?, delay? }) returns a Tween (value, from, to, duration, elapsed, done, step, reset, sampleAt, retarget). tweenTo({ from, to, duration, onChange, ... }) is the imperative version that ticks itself and returns a TweenHandle (active, cancel); number and Color values interpolate by default (Color via OKLab). Supply a custom TweenScheduler to plug into a host loop, or use defaultTweenScheduler().

The easings module exports Easing ((t) => number), linear, the full easeIn/Out/InOut family across quad/cubic/quart/quint/expo/sine/circ/back/elastic/bounce, and cubicBezier(x1, y1, x2, y2) (CSS-compatible).

import { tweenTo, easeOutCubic, rgba } from "insomni";
tweenTo({
from: rgba(1, 0, 0),
to: rgba(0, 0, 1),
duration: 0.4,
easing: easeOutCubic,
onChange: (c) => paint(c),
});

spring(opts) integrates a physical spring; tune by stiffness / damping / mass or derive them from springFromDuration({ duration, bounce }) (bounce: 0 = critically damped, > 0 overshoots, < 0 overdamped). decay(opts) is pure exponential velocity decay with a projectedDistance() for targeting a boundary. Both expose value / velocity / settled plus step, setValue, setVelocity, and reset (spring also has setTarget).

transition<T>(opts) is a fixed-duration, retargetable motion over any T (pass interpolate for non-numbers). It is also a ReadableSignal<T>get / peek / subscribe work in a reactive context — while step(dt) is the imperative driver. TransitionOptions: initial, duration, easing?, interpolate?, equals?, invalidator?. This is what powers the fade in the tooltip and menu UI primitives.

  • Cameras & viewportsmoothDamp and the velocity tracker drive bindViewport.
  • InteractionsInvalidator, transition, and Color feed the UI primitives.
  • Spaces & cameras — where Mat3 projection and Vec2 coordinates are used.