@zakkster/lite-lerp
v1.3.0
Published
Zero-dependency game math primitives — lerp, clamp, damp, smoothDamp, smoothstep, easings, shortest-path angle interpolation, moveToward, wrap, repeat, pingpong, snap, deadzone. Tree-shakeable pure functions, <1KB gzipped.
Maintainers
Readme
@zakkster/lite-lerp
Zero-dependency game math primitives. Tree-shakeable pure functions for game loops, animations, and UI easing.
Import only what you need — bundlers tree-shake the rest to zero.
Why This Library?
- Zero dependencies — nothing to audit, nothing to break
- Frame-rate independent —
damp()gives you buttery smooth motion on 60Hz, 120Hz, and 144Hz displays alike - Pure functions — predictable, testable, no hidden state
- Tiny footprint — < 1KB gzipped (the full surface; a real tree-shaken import is ~80 B)
- Battle-tested math — the same formulas used in Unity, Unreal, and every game engine since the 90s
- Critically-damped smoothing —
smoothDampwith no overshoot, the primitive people leave for a bigger library
Most animation libraries give you a timeline API when all you needed was lerp(a, b, t). This library gives you the building blocks without the opinions.
Installation
npm install @zakkster/lite-lerpQuick Start
import { lerp, damp, clamp, smoothstep, lerpAngle } from '@zakkster/lite-lerp';
// Smooth camera follow (works at any frame rate)
camera.x = damp(camera.x, player.x, 5, deltaTime);
// UI hover scale
button.scale = lerp(button.scale, isHover ? 1.1 : 1.0, 0.15);
// Health bar that never exceeds bounds
const hp = clamp(currentHP, 0, maxHP);
// Critically-damped follow (no overshoot). Reuse the velocity ref every frame.
const camVel = { v: 0 };
camera.x = smoothDamp(camera.x, player.x, camVel, 0.3, deltaTime);Benchmarks & Comparison
Micro‑Benchmarks (Chrome M1, 2026)
| Operation | Ops/sec |
|-----------------|---------|
| lerp() | ~200M |
| damp() | ~180M |
| smoothDamp() | ~120M |
| lerp2To() | ~130M |
| damp2To() | ~110M |
| lerpAngle() | ~120M |
| smoothstep() | ~200M |
Comparison
| Feature | lite‑lerp | GSAP | Anime.js | Popmotion | |---------|-----------|------|----------|-----------| | Zero dependencies | ✔ | ✘ | ✘ | ✘ | | Pure math only | ✔ | ✘ | ✘ | ✘ | | Tree‑shakeable | ✔ | ✘ | ✘ | ✔ | | <1KB | ✔ | ✘ | ✘ | ✘ | | Works in game loops | ✔ | ✘ | ✘ | ✘ |
API Reference
| Function | Description |
|----------|-------------|
| clamp(val, min, max) | Constrain a value to a range |
| lerp(a, b, t) | Linear interpolation (t: 0–1) |
| inverseLerp(a, b, v) | Get the t value of v between a and b |
| mapRange(val, inMin, inMax, outMin, outMax) | Map between ranges |
| remap | Alias for mapRange (Unity/Processing convention) |
| damp(a, b, lambda, dt) | Frame-rate independent smoothing |
| smoothstep(min, max, val) | Hermite interpolation (great for cameras) |
| easeIn(t) | Cubic ease-in (t³) |
| easeOut(t) | Cubic ease-out |
| easeInOut(t) | Cubic ease-in-out |
| lerpAngle(a, b, t) | Shortest-path angle interpolation (degrees) |
| lerpAngleRad(a, b, t) | Shortest-path angle interpolation (radians) |
| lerpUnclamped(a, b, t) | Explicit alias for lerp — see the note below |
| v1.3.0 | |
| lerp2To(ax, ay, bx, by, t, out) | Component-wise lerp for a 2D point into a reused { x, y } — zero alloc |
| damp2To(ax, ay, bx, by, lambda, dt, out) | 2D damp into a reused { x, y }; one shared exponential |
| v1.2.0 | |
| smoothDamp(current, target, ref, smoothTime, dt, maxSpeed?) | Unity SmoothDamp — critically damped, no overshoot; ref is a reused { v: 0 } |
| dampAngle(a, b, lambda, dt) | Frame-rate independent angle smoothing (degrees), shortest-path |
| smoothDampAngle(current, target, ref, smoothTime, dt, maxSpeed?) | smoothDamp for angles (degrees), shortest-path |
| v1.1.0 | |
| moveToward(a, b, maxDelta) | Step toward b by at most maxDelta, never overshooting |
| wrap(v, min, max) | Wrap into [min, max) — toroidal worlds, hue, angles |
| repeat(t, len) | Positive modulo — always in [0, len) |
| pingpong(t, len) | Triangle wave 0 → len → 0 |
| snap(v, step) | Round to the nearest multiple of step |
| deadzone(v, threshold) | Zero inside the threshold, pass-through outside |
| smootherstep(min, max, val) | Quintic Hermite — C² continuous, no acceleration snap |
On
lerpUnclamped. This is not the Unity distinction. In Unity,Mathf.LerpclampstandMathf.LerpUnclampeddoes not. Herelerpdoes not clamp either —lerp(0, 10, 2) === 20. The alias exists only to make extrapolation explicit at the call site. If you want Unity's clampingLerp, clamp it yourself:lerp(a, b, clamp(t, 0, 1)).
Recipes
Camera Follow with Soft Dead Zone
The camera stays still until the player moves beyond a threshold, then smoothly catches up:
const offset = player.x - camera.x;
if (Math.abs(offset) > 50) {
camera.x = damp(camera.x, player.x, 6, dt);
}Smooth UI Hover Animation
No animation library needed — one line in your render loop:
button.scale = lerp(button.scale, isHover ? 1.1 : 1.0, 0.15);
button.opacity = lerp(button.opacity, isVisible ? 1 : 0, 0.1);Health Bar with Color Gradient
lite-lerp gives you the number. For the colour, reach for the peer library — because a naive RGB lerp from green to red dips through a muddy dark brown, and perceived brightness swings wildly across the bar:
import { inverseLerp, lerp, clamp } from '@zakkster/lite-lerp';
import { lerpOklch, toCssOklch } from '@zakkster/lite-color';
const t = clamp(inverseLerp(0, maxHP, currentHP), 0, 1);
bar.style.width = `${lerp(0, 200, t)}px`;
// OKLCH holds lightness and chroma constant and moves only the hue, so the bar
// keeps the same perceived brightness from full health to nearly dead.
const danger = { l: 0.7, c: 0.25, h: 25 }; // red
const healthy = { l: 0.7, c: 0.25, h: 145 }; // green
bar.style.background = toCssOklch(lerpOklch(danger, healthy, t));Numbers from lite-lerp, perception from @zakkster/lite-color.
Critically-Damped Camera / Value Follow (smoothDamp)
damp decays exponentially; smoothDamp is a critically-damped spring — it accelerates, then settles onto the target with no overshoot and a predictable settle time. The velocity is your state: allocate one { v: 0 } and reuse it every frame, so the loop stays zero-GC.
import { smoothDamp } from '@zakkster/lite-lerp';
const camVel = { v: 0 }; // allocate ONCE, outside the loop
function frame(dt) {
// reaches player.x in ~0.3s, never overshoots, optional speed cap of 400 u/s
camera.x = smoothDamp(camera.x, player.x, camVel, 0.3, dt, 400);
}smoothDampAngle does the same for rotations, taking the shortest way round the wrap boundary:
const turnVel = { v: 0 };
turret.angle = smoothDampAngle(turret.angle, targetDeg, turnVel, 0.15, dt);Degenerate inputs are pinned, not undefined: dt <= 0 is a no-op that leaves your velocity ref untouched, smoothTime is floored to 1e-4, and a poisoned (NaN) velocity self-heals to 0 instead of poisoning every later frame.
Smooth 2D Follow (damp2To / lerp2To)
For a point that follows in both axes — camera, cursor, a trailing sprite — damp2To smooths x and y together into a { x, y } object you own. Scalars in, no vector type invented, nothing allocated: reuse the same object every frame and the loop stays zero-GC. It computes the exponential once and is exactly equal to two damp calls.
import { damp2To, lerp2To } from '@zakkster/lite-lerp';
const cam = { x: 0, y: 0 }; // allocate ONCE, outside the loop
function frame(dt) {
// `out` may safely be the point you read from — coords are copied by value
damp2To(cam.x, cam.y, player.x, player.y, 5, dt, cam);
}
// lerp2To is the straight-line version, with bitwise-exact endpoints per axis:
const p = { x: 0, y: 0 };
lerp2To(a.x, a.y, b.x, b.y, 0.5, p); // p is now the midpointNote out must be an object with x/y, not a length-2 array — an array would get .x/.y set as stray properties while [0]/[1] stayed stale. A null out throws, rather than silently doing nothing.
Angle Blending for 2D Sprite Rotation
lerpAngle always takes the shortest path — no more sprites spinning 350° the wrong way. The delta is wrapped into [-180, 180), so this holds for any input, including un-normalized accumulators like rotation += spin * dt that run past 360°:
sprite.rotation = lerpAngle(sprite.rotation, targetAngle, 0.1);
// Turret tracking a moving target
turret.angle = lerpAngle(turret.angle, Math.atan2(dy, dx) * (180/Math.PI), 0.05);Normalized Value Mapping
Convert any measurement to a 0–1 range for use with gradients, audio, or UI:
const heat = inverseLerp(0, 100, temperature); // 0–100°C → 0–1
const color = heatmap(heat);
// Mouse position → rotation angle
const angle = mapRange(mouseX, 0, screenWidth, -45, 45);Smooth Page Scroll Progress
const scrollT = inverseLerp(0, maxScroll, window.scrollY);
const parallaxY = lerp(0, -200, smoothstep(0, 1, scrollT));
background.style.transform = `translateY(${parallaxY}px)`;Frame-Rate Independent Exponential Decay
damp() produces identical visual results whether the game runs at 30fps or 144fps:
// These look the same on any display:
value = damp(value, target, 5, 1/30); // 30fps frame
value = damp(value, target, 5, 1/144); // 144fps frame (4.8x more frames, same visual speed)Smooth Dialog Box Entry
const t = clamp(inverseLerp(startTime, startTime + 300, now), 0, 1);
dialog.y = lerp(screenHeight, centerY, easeOut(t));
dialog.opacity = t;Input Smoothing (deadzone + moveToward + damp)
import { deadzone, moveToward, damp, mapRange, clamp } from '@zakkster/lite-lerp';
// Raw deadzone is discontinuous: output jumps from 0 to ±0.15 at the boundary.
// Rescale the remainder so the stick ramps smoothly out of the dead centre.
const DZ = 0.15;
const stick = (v) => (deadzone(v, DZ) === 0 ? 0 : Math.sign(v) * mapRange(Math.abs(v), DZ, 1, 0, 1));
// Speed-capped movement, then frame-rate independent smoothing.
player.x = moveToward(player.x, player.x + stick(gamepad.axes[0]) * 300, maxSpeed * dt);
camera.x = damp(camera.x, player.x, 6, dt);Looping Animation (pingpong + smootherstep)
import { pingpong, smootherstep, lerp } from '@zakkster/lite-lerp';
const t = smootherstep(0, 1, pingpong(elapsed * 0.5, 1)); // eased breathe, 0→1→0
sprite.scale = lerp(1, 1.2, t);Grid Snapping (snap + wrap)
import { snap, wrap } from '@zakkster/lite-lerp';
node.x = snap(pointer.x, 16); // 16px grid
enemy.x = wrap(enemy.x + vx * dt, 0, world.width); // toroidal world
hue = wrap(hue + 1, 0, 360); // hue never leaves [0, 360)Bundle Size
The < 1KB claim is enforced, not aspirational, and it means min+gzip — exactly what Bundlephobia's badge above measures. npm run size builds the full export surface with esbuild, then checks both the gzip figure and the raw-minified surface, failing the build if either regresses:
size gate passed -- 1599 B minified / 1664 B budget (96.1%), 65 B headroom
821 B min+gzip / 1024 B (the public "< 1KB" figure), 203 B headroomIt runs on prepublishOnly, so the claim cannot silently go stale. v1.2.0's smoothDamp family (~380 B of spring math) and v1.3.0's 2D helpers (~150 B) push the raw-minified surface past 1 KB, so that internal ceiling moved to 1664 B while the public gzip figure stays comfortably under 1 KB.
Because sideEffects: false and every export is a pure function, a real import costs far less than the full surface: lerp + clamp is ~80 B minified.
Division Safety
inverseLerp(5, 5, v) returns 0 instead of NaN. This prevents cascade failures through mapRange and smoothstep when min equals max — a common edge case with dynamic data ranges.
TypeScript
Every function is fully typed with JSDoc and .d.ts declarations:
import { lerp, damp, type mapRange } from '@zakkster/lite-lerp';License
MIT
