Skip to content

color

Terminal window
bun add @stopcock/color

A 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. Color-value operations support direct data-first and curried data-last calls under the same name.

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'
const brand = fromHex('#2563eb')
const lighter = lighten(brand, 0.1) // direct, data-first
const accent = pipe(brand, lighten(0.1), desaturate(0.2), adjustHue(15), toHex)
// => '#647dd1'

Try it live in the interactive showcase.

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) / 2 in 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(c, 'hsl') or pipe(a, mixIn(b, 'hsl', t)) when you need legacy behavior.

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.

rgb(r, g, b, alpha?) // 0-1 each
rgb255(r, g, b, alpha?) // 0-255 each
hsl(h, s, l, alpha?) // h: 0-360, s/l: 0-1
oklch(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-d65
fromHex('#2563eb')
fromCSS('oklch(0.7 0.15 250)')

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(c: Color, target: ColorSpace): Color
convert(target: ColorSpace): (c: Color) => Color

Arity-1 convenience aliases that pipe directly:

;(toSRGB, toLinearRGB, toHSL, toHWB, toLab, toLCh, toOKLab, toOKLCh, toP3, toXYZ, toXYZ50)

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 { pipe } from '@stopcock/fp'
import { convertBuffer, simulateBuffer, toGamutBuffer, applyMatrix3x3 } from '@stopcock/color'
const pixels = new Float64Array([1, 0, 0, 0, 1, 0, 0, 0, 1])
const oklab = convertBuffer(pixels, 'srgb', 'oklab')
const oklabInPipe = pipe(pixels, convertBuffer('srgb', 'oklab'))
const simulated = simulateBuffer(pixels, 'srgb', 'deuteranopia', 1)
const simulatedInPipe = pipe(pixels, simulateBuffer('srgb', 'deuteranopia', 1))
const mapped = toGamutBuffer(pixels, 'p3', 'srgb')
const mappedInPipe = pipe(pixels, toGamutBuffer('p3', 'srgb'))
const transformed = applyMatrix3x3(new Float64Array([1, 0, 0, 0, 1, 0, 0, 0, 1]), pixels)
convertBuffer(src, srcSpace, dstSpace, out?)
convertBuffer(srcSpace, dstSpace, out?)(src)
simulateBuffer(src, srcSpace, type, severity?, out?)
simulateBuffer(srcSpace, type, severity?, out?)(src)
toGamutBuffer(src, srcSpace, targetSpace, out?)
toGamutBuffer(srcSpace, targetSpace, out?)(src)

The optional out buffer is returned from either lane. Alpha stays out-of-band. @stopcock/img handles RGBA byte splitting and recombination for whole-image filters.

All route through OKLCh, then return to the source space.

lighten(c: Color, amount: number): Color
lighten(amount: number): (c: Color) => Color
darken(c: Color, amount: number): Color
darken(amount: number): (c: Color) => Color
saturate(c: Color, amount: number): Color
saturate(amount: number): (c: Color) => Color
desaturate(c: Color, amount: number): Color
desaturate(amount: number): (c: Color) => Color
adjustHue(c: Color, degrees: number): Color
adjustHue(degrees: number): (c: Color) => Color
adjustAlpha(c: Color, alpha: number): Color
adjustAlpha(alpha: number): (c: Color) => Color
mix(a: Color, b: Color, t?: number): Color
mix(b: Color, t?: number): (a: Color) => Color // OKLab, t defaults to 0.5
mixIn(a: Color, b: Color, space: ColorSpace, t?: number): Color
mixIn(b: Color, space: ColorSpace, t?: number): (a: Color) => Color // explicit space
hueInterpolate(h1: number, h2: number, t: number): number
hueInterpolate(h2: number, t: number): (h1: number) => number // shorter arc
const halfway = hueInterpolate(350, 10, 0.5)
const halfwayInPipe = pipe(350, hueInterpolate(10, 0.5))
complementary(c): Color
triadic(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[]
const direct = analogous(brand, 5)
const inPipe = pipe(brand, analogous(5))

Harmony palettes rotate the hue in OKLCh and project back to the source space, so the resulting colors share lightness/chroma. They “go together.”

;(red(c), green(c), blue(c)) // sRGB 0-1
;(lightness(c), chroma(c), hue(c)) // OKLCh
alpha(c)
toHex(c): string // '#rrggbb' or '#rrggbbaa'
toCSS(c): string // 'oklch(L C H / a)'
toRGBString(c): string // 'rgb(r g b / a)'
toHSLString(c): string
luminance(c): number // WCAG relative luminance
contrastRatio(a: Color, b: Color): number
contrastRatio(b: Color): (a: Color) => number
meetsAA(a: Color, b: Color): boolean
meetsAA(b: Color): (a: Color) => boolean
meetsAAA(a: Color, b: Color): boolean
meetsAAA(b: Color): (a: Color) => boolean
meetsAALarge(a: Color, b: Color): boolean
meetsAALarge(b: Color): (a: Color) => boolean
deltaE(a: Color, b: Color): number
deltaE(b: Color): (a: Color) => number
deltaEOK(a: Color, b: Color): number
deltaEOK(b: Color): (a: Color) => number
inGamut(c: Color, target: ColorSpace): boolean
inGamut(target: ColorSpace): (c: Color) => boolean
toGamut(c: Color, target: ColorSpace): Color
toGamut(target: ColorSpace): (c: Color) => Color

toGamut 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.

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): Color
simulate(type: CVDType, severity?: number): (c: Color) => Color

severity 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 = pipe(myBrand, simulate('deuteranopia', 0.5))
paletteContrastMatrix(palette: Color[]): ContrastCell[][]
minDistinguishableDistance(palette: Color[], type: CVDType, severity?): number
minDistinguishableDistance(type: CVDType, severity?): (palette: Color[]) => 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.

import { pipe } from '@stopcock/fp'
import { fromHex, toOKLCh, lighten, darken, toHex } from '@stopcock/color'
const brand = fromHex('#2563eb')
const theme = {
primary: toHex(brand),
primaryHover: pipe(brand, lighten(0.05), toHex),
primaryActive: pipe(brand, darken(0.05), toHex),
primaryMuted: toHex(pipe(brand, lighten(0.4))),
}
import { pipe } from '@stopcock/fp'
import { contrastRatio, fromHex, rgb } from '@stopcock/color'
const white = rgb(1, 1, 1)
const black = rgb(0, 0, 0)
const onBg = (bg: Color) => (pipe(white, contrastRatio(bg)) >= 4.5 ? white : black)

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.