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

  1. A color is three plain numbers in a named space: [0.7, 0.15, 250] in 'oklch'. No wrapper class, ever.
  2. 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.
  3. 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.
  4. 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).
  5. 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:

taskspacewhy
UI colors, design tokensoklchperceptually uniform lightness + chroma, CSS-native, hue stays put when you adjust L or C
color picker UIsokhsl / okhsvOKLab quality with bounded 0–1 sliders (sRGB-shaped by design)
gradients & mixingoklch (or oklab to avoid hue arcs)no muddy middles; mix() implements all four CSS hue arcs
"how different are these?"deltaEOK · deltaE2000 · deltaECAM16quick UI checks · industry/textiles · viewing-condition-aware
accessibilitycontrastWCAG2the binding standard (APCA ships when WCAG 3 freezes)
wide gamut / HDRdisplay-p3, rec2020 / rec2100-pq, ictcp, jzazbzsee the gamut section below
film & VFX interchangeaces2065-1, acescg, acesccthe AMPAS pipeline, ~D60 white handled through the real CAT machinery
Material Design parityhctbit-compatible with Google's HCT (verified against their library)
research / measurementxyz-d65, lab + the spectral layerthe 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

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.