the guide
From your first CSS string to emitted shaders. Every example on this page runs live against the library — the green boxes are computed in your browser right now, so the documentation cannot lie about its outputs.
Start here
npm install whitepoint
Ten seconds to first conversion: strings parse at the front door, coordinates come out in any of the 42 spaces, and strings go back out for CSS.
import { parseTo, convert, serialize, mix } from 'whitepoint';
parseTo('#ff8800', 'oklch'); // hex → OKLCH coordinates
convert([0.7, 0.15, 250], 'oklch', 'display-p3');
serialize([0.7, 0.15, 250], 'oklch'); // → CSS string
mix('#0066ff', 'tomato', 0.5, 'oklch'); // strings work in mix too? No — see below
One honest correction to that last line: mix() takes
coordinates, not strings — the string boundary is parse/parseTo/serialize,
and everything inside works on plain arrays. The working version:
mix(parseTo('#0066ff', 'oklch'), parseTo('tomato', 'oklch'), 0.5, 'oklch');
The mental model — five sentences
- A color is three plain numbers in a named space:
[0.7, 0.15, 250]in'oklch'. No wrapper class, ever. - Channels are 0–1 floats; hue channels are degrees; CIE Lab/LCH lightness is 0–100 (per spec) while OKLab/OKLCH lightness is 0–1 — this is the one pitfall worth memorizing.
- Every conversion routes through XYZ-D65 (with precomposed fast paths), so any space converts to any other, including ones that have never heard of each other.
- Alpha is not a coordinate. It rides along in
parse()'s result and in the explicit alpha-aware entry points (mixAlpha,composite,serialize's option). - Nothing rounds until you ask. Bytes and hex are explicit boundaries (
toBytes/toHex); internally it's float64 at ~10⁻¹⁵ round-trip error.
Which space should I use?
The honest cheat sheet, by task:
| task | space | why |
|---|---|---|
| UI colors, design tokens | oklch | perceptually uniform lightness + chroma, CSS-native, hue stays put when you adjust L or C |
| color picker UIs | okhsl / okhsv | OKLab quality with bounded 0–1 sliders (sRGB-shaped by design) |
| gradients & mixing | oklch (or oklab to avoid hue arcs) | no muddy middles; mix() implements all four CSS hue arcs |
| "how different are these?" | deltaEOK · deltaE2000 · deltaECAM16 | quick UI checks · industry/textiles · viewing-condition-aware |
| accessibility | contrastWCAG2 | the binding standard (APCA ships when WCAG 3 freezes) |
| wide gamut / HDR | display-p3, rec2020 / rec2100-pq, ictcp, jzazbz | see the gamut section below |
| film & VFX interchange | aces2065-1, acescg, acescc | the AMPAS pipeline, ~D60 white handled through the real CAT machinery |
| Material Design parity | hct | bit-compatible with Google's HCT (verified against their library) |
| research / measurement | xyz-d65, lab + the spectral layer | the hub itself, and physics underneath it |
Live, so you can feel the difference — the same two colors mixed three ways:
const a = parseTo('#0066ff', s), b = parseTo('#ffcc00', s); // for s of …
serialize(mix(a, b, 0.5, s), s)
Wide gamut without tears
Modern displays show more than sRGB; CSS can express more than any display. The workflow is three verbs:
import { inGamut, toGamut, serialize, parseTo } from 'whitepoint';
const hot = parseTo('color(display-p3 1 0.2 0)', 'oklch'); // a P3-only red
inGamut(hot, 'oklch', 'srgb'); // can sRGB show it?
inGamut(hot, 'oklch', 'display-p3'); // can P3?
// for the sRGB fallback, map it INTO the gamut — don't clip channels
const fallback = toGamut(hot, 'oklch', { gamut: 'srgb', method: 'cusp' });
Three mapping methods, in one sentence each: clip clamps channels (fast, shifts hue — fine for previews); css is the CSS Color 4 §13 reference algorithm (what browsers are specified to do); cusp projects toward the gamut cusp in OKLCH with the cusp solved exactly from the gamut's own definition — hue preserved to machine precision, and the reason the accuracy page has its favorite chart. The CSS pattern for shipping both:
@supports (color: color(display-p3 1 1 1)) { /* serialize(hot, 'display-p3') */ }
/* fallback: serialize(toGamut(hot, …), 'srgb') */
Into the shader
This is the part no other color library does: the function you just used in JS can be emitted as GLSL or WGSL, generated from the same single-sourced constants, and CI executes every emitted program on a real GPU to confirm the outputs match the float64 library. Your gradient on the CPU and your gradient on the GPU stop being two implementations that drift — they're one derivation, printed twice.
import { glsl, wgsl, glslMix, glslGamutMap } from 'whitepoint/codegen';
glsl('oklch', 'display-p3'); // vec3 wp_oklch_to_display_p3(vec3 c)
wgsl('jzazbz', 'srgb'); // the same chain as WGSL
glslMix('oklch'); // per-pixel CSS-correct mixing
glslGamutMap('srgb'); // the exact-cusp mapper, in-shader
What's emittable: every matrix/transfer-function chain among the
registry spaces, plus hand-templated emitters for the solver
spaces (okhsl, okhsv,
hsluv, hpluv) and the appearance models
(cam16-ucs, hct) — solvers and all.
Paste the output into your fragment shader, or template it at build time; the code is
dependency-free and self-contained.
The spectral layer — color before it was three numbers
Everything above treats color as coordinates. whitepoint/spectral
goes one level down, to light as a function of wavelength — because some questions
can't be answered any other way: what does this color look like under candlelight?
what's the CRI of this lamp? what happens to red at 20 meters underwater?
import { reflectanceOf, reflectanceToXyz, planckianSPD, cri, tm30 }
from 'whitepoint/spectral';
// "this hex, under candlelight": give the color a plausible spectrum,
// re-light it, convert back
const refl = reflectanceOf(parseTo('#ff8800', 'srgb'));
const candle = planckianSPD(1850);
const xyz = reflectanceToXyz(refl, { illuminant: candle });
// grade a lamp like a lighting engineer
cri(FL2_SPD); // CIE 13.3: { Ra: 64, Ri[…], R9: −84 }
tm30(FL2_SPD); // IES TM-30-20: { Rf: 70, Rg: 86 }
Sometimes there is no illuminant to look up. Nobody ever standardized neon — so
the layer derives it: NIST atomic transitions under an optically-thin Boltzmann
model with one effective parameter, the excitation temperature. And when tabulated
data arrives on the wrong grid, resample() moves it the
way CIE 167:2005 says to — Sprague's quintic, exact at the nodes.
import { emissionColor, emissionSPD, resample, FL2_SPD } from 'whitepoint/spectral';
// the one-liner: a named emitter → a render-ready, gamut-safe color
emissionColor('neon', { to: 'oklch', gamut: 'srgb' }); // → vivid red-orange
emissionColor('neon', { gamut: 'display-p3' }); // → a redder neon a wide screen shows
// or drop a level for the spectrum itself (a sign, a flame metal, your own lines)
const neon = emissionSPD('neon'); // powers ∝ (g·A/λ)·e^(−E/kT)
const fine = resample(FL2_SPD, { step: 1 }); // 5 nm table → 1 nm grid
The neon alley is exactly this code with a renderer behind it. The layer is deep — CMFs at 5 nm and 1 nm, Planck's law, CIE daylight synthesis, CCT solving on the exact locus, color-blindness simulation, scotopic/mesopic vision, Beer–Lambert attenuation with measured water data, Kubelka–Munk paint mixing, and the Hosek–Wilkie sky with its solar disc. The light lab runs all of it live; the recipes have the copy-paste versions.
The performance playbook
Three tiers, slowest to fastest — pick by how hot your loop is:
// 1. strings + ids: fine for setup code (~80 ns with allocation)
const c = convert(coords, 'oklch', 'srgb');
// 2. space objects + out-array: the zero-allocation hot path (~47 ns)
import { OKLCH, sRGB } from 'whitepoint';
const out = [0, 0, 0];
convert(coords, OKLCH, sRGB, out);
// 3. buffers: a megapixel in one call, route resolved once (~46 ns/px)
convertBuffer(float32Pixels, 'oklch', 'srgb'); // in place
Those numbers are measured, on-record, and competitive with (mostly ahead of) every JS color library we know of — methodology and the comparison table live in the README. The zero-allocation claim is enforced by a CI audit on retained memory, not asserted.
Why you can trust the numbers
- No transcribed matrices. Every RGB↔XYZ matrix is derived from the cited primaries at module load and asserted against the spec's published values in CI — which is how this library caught the stale 10-digit ProPhoto/Bradford folklore other libraries still ship.
- No hand-typed data. Every spectral table arrives via a committed generator script from a cited source (CVRL, CIE, W3C, OMLC, the Hosek–Wilkie distribution) — even the 148 named colors are parsed out of the CSS spec.
- Oracles, not vibes. The suite compares against culori, colorjs.io, @texel/color, Google's Material library, python colour-science, and — for the sky — the authors' own compiled C, digit for digit.
- The hostile-input contract. Conversions never throw and never hang; NaN and ±Infinity flow through as garbage-in-garbage-out; in-domain inputs always produce finite outputs. Fuzzed in CI.
The full evidence is on the accuracy page; the policy statements live in the north star — including the anti-goals, and the one we amended on purpose.