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.

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.

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).

FunctionSignatureResult
vec2(x, y) => Vec2Construct a vector.
add(a, b) => Vec2a + b.
sub(a, b) => Vec2a - b.
scale(v, s) => Vec2v * s.
length(v) => numberEuclidean magnitude.
normalize(v) => Vec2Unit vector ({0,0} if zero-length).
dot(a, b) => numberDot 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.

FunctionDescription
IDENTITYThe 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);
FunctionDescription
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].

FunctionDescription
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 / hslToRgbaAliases of hsl.
FunctionDescription
lerpColor(a, b, t)Channel-wise linear interpolation.
mixColorAlias 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).

FunctionDescription
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.

FunctionDescription
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.

FunctionDescription
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);
SymbolDescription
TAU, PI, HALF_PI, π, π/2.
DEG_TO_RAD, RAD_TO_DEGConversion 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.)

GeneratorNotes
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.
DistributionDescription
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.

MemberDescription
dirtyTrue 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.

FactoryModel
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.