npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zakkster/lite-color-lerp

v1.0.1

Published

Zero-GC OKLCH to ARGB Uint32 gradient baking and hot-path sampling for high-performance ECS render loops.

Downloads

224

Readme

@zakkster/lite-color-lerp

npm version npm bundle size npm downloads npm total downloads TypeScript Dependencies License: MIT

Zero-GC OKLCH gradient baking and hot-path color sampling for high-throughput render loops.

Author your gradients in perceptually-uniform OKLCH space. Ship them as raw Uint32Array lookup tables. Sample millions of colors per second with pure integer math — zero allocations in the hot path.

┌─────────────────────────────┐         ┌──────────────────────────────┐
│   AUTHORING (init-time)     │         │   RUNTIME (per frame)        │
│                             │         │                              │
│   OKLCH stops               │  bake   │   sampleColorLUT(lut, t)     │
│      ↓                      │ ──────▶ │      ↓                       │
│   Ottosson 2020 → sRGB      │ once    │   Uint32 ARGB / RGBA-LE      │
│      ↓                      │         │      ↓                       │
│   Uint32Array LUT           │         │   Direct write to ImageData  │
└─────────────────────────────┘         └──────────────────────────────┘

Why this exists

Modern color theory has converged on OKLCH (Björn Ottosson, 2020) for one reason: equal numerical steps in OKLCH space produce equal perceptual steps to the human eye. RGB and HSL are not perceptually uniform — interpolating through them produces dead grey midpoints, banding, and unintended hue shifts.

The catch: OKLCH → sRGB conversion is heavy. A trig call, three matrix multiplications, three cubings, three gamma transfers, and four quantizations — per pixel, per frame. For a 100k particle system at 60fps that's 24 million OKLCH conversions per second. Even with a JIT, the per-call object allocations from naive implementations will tank the GC.

lite-color-lerp solves this by separating authoring from runtime. You bake a perceptually-correct gradient once into a flat Uint32Array. Per frame, you do an integer multiply, an | 0, and an array read. That's the entire hot path.


Install

npm install @zakkster/lite-color-lerp

ESM only. Requires Node 18+ for development; the published artifact runs anywhere modern ES modules work (browser, Bun, Deno, Node).

Peer of @zakkster/lite-color, which provides the OKLCH multistop interpolator.


Quick start

import {
    bakeGradientLUT,
    bakeGradientLUTRGBA,
    sampleColorLUT,
} from '@zakkster/lite-color-lerp';

// 1. Author your gradient in OKLCH.
const sunset = [
    { l: 0.45, c: 0.20, h: 25  },   // deep crimson
    { l: 0.75, c: 0.18, h: 60  },   // amber
    { l: 0.92, c: 0.12, h: 95  },   // pale gold
];

// 2. Bake once at init.
const lut = bakeGradientLUT(sunset, 256);   // 1 KB. That's your gradient.

// 3. Sample in your hot loop. Zero allocations, zero method dispatch.
function tick() {
    for (let i = 0; i < entityCount; i++) {
        const t = age[i] / lifespan[i];
        const argb = sampleColorLUT(lut, t);
        // ... use argb ...
    }
}

API

bakeGradientLUT(stops, resolution = 256)Uint32Array

Bakes an OKLCH gradient into a Uint32Array packed as ARGB (0xAARRGGBB).

Use this variant when you intend to:

  • Build CSS hex strings via '#' + (v & 0xFFFFFF).toString(16)
  • Unpack channels manually for a WebGL uniform
  • Store colors in a debugger or human-readable log

| Parameter | Type | Default | Notes | |--------------|------------------|---------|-----------------------------------------| | stops | OklchColor[] | — | Non-empty. { l, c, h, a? }. | | resolution | number | 256 | LUT size. Min 2. 256 is plenty for most gradients. |

Throws on empty stops or resolution < 2.

bakeGradientLUTRGBA(stops, resolution = 256)Uint32Array

Same input, same output type — but packed for direct writes into Canvas2D ImageData.

Browsers store ImageData.data as [R, G, B, A] bytes. On any little-endian machine (every browser ever), reading those four bytes as a Uint32 yields (A<<24) | (B<<16) | (G<<8) | R. This variant pre-packs the channels so you can skip channel-swapping at runtime:

const lut = bakeGradientLUTRGBA(sunset, 256);
const pixels = new Uint32Array(imageData.data.buffer);

// Drop a colored pixel at (x, y) — no channel math, no allocations.
pixels[y * width + x] = sampleColorLUT(lut, t);
ctx.putImageData(imageData, 0, 0);

sampleColorLUT(lut, t)number

The hot path. Maps progress t ∈ [0, 1] to a packed Uint32 color.

Behavior:

  • t < 0 clamps to lut[0].
  • t > 1 clamps to lut[lut.length - 1].
  • NaN returns lut[0] (NaN comparisons are false; NaN | 0 === 0).
  • Sampling is nearest-neighbor. At 256 entries this is visually indistinguishable from interpolation for any sane gradient.

Zero allocations. No bounds check. No method dispatch. This function is six instructions on the JIT.


Picking a byte order

| You're doing this... | Use | |------------------------------------------------------|---------------------------| | Writing pixels to ImageData via Uint32Array view | bakeGradientLUTRGBA | | Hex strings, CSS, debug logs, manual channel math | bakeGradientLUT | | WebGL — depends on your unpack convention | Whichever matches yours | | Canvas2D fillStyle = '#rrggbb' | bakeGradientLUT + format |

If you're unsure, bakeGradientLUTRGBA is the right default for browser graphics — it's the only path that lets you skip a per-pixel channel swap.


Performance characteristics

The bake phase is O(resolution) and runs once. At resolution 256 you're doing 256 OKLCH→sRGB conversions — sub-millisecond on any device made this decade.

The runtime phase per call:

sampleColorLUT(lut, t):
    cmp t, 0           ; clamp branch
    cmp t, 1           ; clamp branch
    fmul tc, maxIdx    ; scale
    cvtss              ; | 0  →  truncate-to-int
    mov  eax, [lut+ecx*4]
    ret

No object allocation. No closure. No prototype lookup. The result is a primitive number — V8 will generally keep it in a register through your inner loop.

Memory cost: 4 bytes per LUT entry. A 256-entry LUT is 1 KB. Even at 4096 entries you're at 16 KB — a single CPU L1 cache line cluster.


Real-world example: 100k-particle gradient field

See Demo.html in the repo. The hot loop:

for (let i = 0; i < COUNT; i = (i + 1) | 0) {
    pX[i] += pVx[i];
    pY[i] += pVy[i];
    pLife[i]++;

    const t = pLife[i] / pMaxLife[i];
    const color = sampleColorLUT(colorLUT, t);

    const px = pX[i] | 0;
    const py = pY[i] | 0;
    pixels[py * width + px] = color;
}
ctx.putImageData(imageData, 0, 0);

100,000 particles, perceptually-graded color, direct-to-memory pixel writes. On modern hardware this runs at a stable 60 fps with zero garbage collection events.


Notes

  • Out-of-gamut colors (chroma values outside the sRGB triangle) are clamped during bake. Currently, this is a hard channel-clamp; gamut-mapped variants (preserving lightness or hue) are a future addition.
  • Nearest-neighbor sampling is the default. For most gradients at resolution 256 the artifacts are imperceptible. A linear-interpolated sampler would require unpacking, lerping in linear-light space, and repacking — defeating the zero-GC contract. Don't ask for it unless you have a measured artifact.
  • Single-source-of-truth math. The Ottosson coefficients used here match the reference C implementation exactly.

License

MIT. See LICENSE.txt.