color
bun add @stopcock/colorA color algebra in pure TypeScript. 11 color spaces with lossless conversions (CSS Color 4 matrices), perceptually-uniform adjustments via OKLCh, WCAG contrast, CIEDE2000 perceptual distance, gamut mapping, and color-vision-deficiency simulation. Every transformation is curried, data-last, so it composes with pipe.
Try the API in the Color showcase, or see it compose with image processing and procedural graphics in the SVG + Color Batch showcase.
import { pipe } from '@stopcock/fp'import { fromHex, lighten, desaturate, adjustHue, toHex } from '@stopcock/color'
pipe(fromHex('#2563eb'), lighten(0.1), desaturate(0.2), adjustHue(15), toHex)// => '#647dd1'Try it live in the interactive showcase.
Why OKLCh
Section titled “Why OKLCh”The package routes every perceptual operation (lighten, darken, saturate, mix) through OKLCh by default. OKLCh is a polar form of OKLab, which is the most accurate perceptual color space currently published (Ottosson, 2020). Compared to HSL:
- Lightening blue in HSL turns it pink. Lightening blue in OKLCh keeps it blue.
- Equal lightness steps in OKLCh look like equal lightness steps. HSL’s lightness is just
(max + min) / 2in sRGB, which is heavily biased. - Mixing two colors in OKLab avoids the muddy gray middle that sRGB interpolation produces.
You can still operate in any space explicitly with convert('hsl')(c) or mixIn(b, 'hsl', t)(a) when you need legacy behavior.
Representation
Section titled “Representation”type ColorSpace = | 'srgb' | 'linear-srgb' | 'hsl' | 'hwb' | 'lab' | 'lch' | 'oklab' | 'oklch' | 'p3' | 'xyz-d50' | 'xyz-d65'
type Color = { readonly space: ColorSpace readonly channels: Float64Array // always length 3 readonly alpha: number // 0-1}Alpha lives outside the channels array because it’s orthogonal to color-space conversion: converting sRGB to OKLab does not touch alpha.
Construction
Section titled “Construction”rgb(r, g, b, alpha?) // 0-1 eachrgb255(r, g, b, alpha?) // 0-255 eachhsl(h, s, l, alpha?) // h: 0-360, s/l: 0-1oklch(l, c, h, alpha?)oklab(l, a, b, alpha?)lab(l, a, b, alpha?)lch(l, c, h, alpha?)p3(r, g, b, alpha?)xyz(x, y, z, alpha?) // xyz-d65fromHex('#2563eb')fromCSS('oklch(0.7 0.15 250)')Conversion
Section titled “Conversion”Curried. Routes through a hub-and-spoke graph (XYZ-D65 at the center) with memoized BFS: pay the graph walk once per unique pair, O(1) after.
convert(target: ColorSpace): (c: Color) => ColorArity-1 convenience aliases that pipe directly:
;(toSRGB, toLinearRGB, toHSL, toHWB, toLab, toLCh, toOKLab, toOKLCh, toP3, toXYZ, toXYZ50)Batch buffers
Section titled “Batch buffers”For image-sized workloads, use the batch API instead of looping over Color objects. A channel buffer is a Float64Array packed as RGB triples:
import { convertBuffer, simulateBuffer, applyMatrix3x3 } from '@stopcock/color'
const rgb = new Float64Array([1, 0, 0, 0, 1, 0, 0, 0, 1])
const oklab = convertBuffer(rgb, 'srgb', 'oklab')const simulated = simulateBuffer(rgb, 'srgb', 'deuteranopia', 1)const transformed = applyMatrix3x3(new Float64Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), rgb)Alpha stays out-of-band. @stopcock/img handles RGBA byte splitting and recombination for whole-image filters.
Adjustments
Section titled “Adjustments”All route through OKLCh, then return to the source space. Curried.
lighten(amount: number): (c: Color) => Colordarken(amount: number): (c: Color) => Colorsaturate(amount: number): (c: Color) => Colordesaturate(amount: number): (c: Color) => ColoradjustHue(degrees: number): (c: Color) => ColoradjustAlpha(alpha: number): (c: Color) => ColorMixing
Section titled “Mixing”mix(b: Color, t?: number): (a: Color) => Color // OKLab, t defaults to 0.5mixIn(b: Color, space: ColorSpace, t?: number): (a: Color) => Color // explicit spacehueInterpolate(h1: number, h2: number, t: number): number // shorter-arc, not curriedPalettes
Section titled “Palettes”complementary(c): Colortriadic(c): [Color, Color, Color]tetradic(c): [Color, Color, Color, Color]splitComplementary(c): [Color, Color, Color]analogous(c: Color, count?: number, angle?: number): Color[]analogous(count?, angle?): (c: Color) => Color[] // curried for pipeHarmony palettes rotate the hue in OKLCh and project back to the source space, so the resulting colors share lightness/chroma. They “go together.”
Channels
Section titled “Channels”;(red(c), green(c), blue(c)) // sRGB 0-1;(lightness(c), chroma(c), hue(c)) // OKLChalpha(c)Formatting
Section titled “Formatting”toHex(c): string // '#rrggbb' or '#rrggbbaa'toCSS(c): string // 'oklch(L C H / a)'toRGBString(c): string // 'rgb(r g b / a)'toHSLString(c): stringContrast and distance
Section titled “Contrast and distance”luminance(c): number // WCAG relative luminancecontrastRatio(b: Color): (a: Color) => number // WCAG ratio, curriedmeetsAA(b: Color): (a: Color) => boolean // ratio >= 4.5, curriedmeetsAAA(b: Color): (a: Color) => boolean // ratio >= 7, curriedmeetsAALarge(b: Color): (a: Color) => boolean // ratio >= 3, currieddeltaE(b: Color): (a: Color) => number // CIEDE2000 perceptual distance, currieddeltaEOK(a, b): number // Euclidean distance in OKLab, not curriedGamut mapping
Section titled “Gamut mapping”inGamut(target: ColorSpace): (c: Color) => booleantoGamut(target: ColorSpace): (c: Color) => ColortoGamut uses the CSS Color 4 algorithm: binary search in OKLCh chroma, preserving lightness and hue, until the result is in the target gamut and within JND (deltaEOK < 0.02) of the clipped variant. Better than naive RGB clipping, which shifts the hue.
Color vision deficiency
Section titled “Color vision deficiency”Simulate how a color appears to a viewer with a CVD condition, using Machado et al. (2009) matrices.
type CVDType = 'protanopia' | 'deuteranopia' | 'tritanopia' | 'achromatopsia'simulate(c: Color, type: CVDType, severity?: number): Colorseverity is 0 (normal) to 1 (full dichromacy); intermediate values blend between identity and the full matrix. Ignored for achromatopsia.
import { simulate } from '@stopcock/color'
const asProtan = simulate(myBrand, 'protanopia')const partialDeutan = simulate(myBrand, 'deuteranopia', 0.5)Accessibility helpers
Section titled “Accessibility helpers”paletteContrastMatrix(palette: Color[]): ContrastCell[][]minDistinguishableDistance(palette: Color[], type: CVDType, severity?): number
type ContrastCell = { ratio: number; aa: boolean; aaLarge: boolean; aaa: boolean }paletteContrastMatrix returns the full N×N pairwise contrast grid. Pick foreground/background pairs visually.
minDistinguishableDistance simulates a palette under a given CVD condition and reports the smallest pairwise distance. A high minimum means every color in your palette stays distinct under that CVD.
Real-world patterns
Section titled “Real-world patterns”Theme generation
Section titled “Theme generation”import { pipe } from '@stopcock/fp'import { fromHex, toOKLCh, lighten, darken, toHex } from '@stopcock/color'
const brand = fromHex('#2563eb')const theme = { primary: toHex(brand), primaryHover: toHex(lighten(0.05)(brand)), primaryActive: toHex(darken(0.05)(brand)), primaryMuted: toHex(pipe(brand, lighten(0.4))),}Picking accessible text on any background
Section titled “Picking accessible text on any background”import { contrastRatio, fromHex, rgb } from '@stopcock/color'
const onBg = (bg: Color) => (contrastRatio(bg)(rgb(1, 1, 1)) >= 4.5 ? rgb(1, 1, 1) : rgb(0, 0, 0))Building a colorblind-safe categorical palette
Section titled “Building a colorblind-safe categorical palette”import { analogous, fromHex, minDistinguishableDistance } from '@stopcock/color'
const candidate = analogous(fromHex('#2563eb'), 8, 45)const safety = minDistinguishableDistance(candidate, 'deuteranopia')// If safety is too low, widen the hue angle.