npm.io
7.3.0 • Published 1 week ago

@colordx/core

Licence
MIT
Version
7.3.0
Deps
0
Size
357 kB
Vulns
0
Weekly
0

colordx

@colordx/core

npm version used by cssnano used by oklch-picker bundle size npm downloads zero dependencies CI

Try it on colordx.dev

A modern color manipulation library built for the CSS Color 4 era, with first-class support for OKLCH and OKLab. 8.8 KB gzipped (6.7 KB for colordx() alone). 0 Dependencies.

Contents

Install

npm install @colordx/core

Quick start

import { colordx } from '@colordx/core';

// Parse any CSS color string or color object, then chain conversions:
colordx('#ff0000').toRgbString();     // 'rgb(255 0 0)'
colordx('#ff0000').toHex();           // '#ff0000'
colordx('#ff0000').toOklch();         // { l: 0.62796, c: 0.25768, h: 29.23388, alpha: 1 }
colordx('#ff0000').toOklchString();   // 'oklch(0.62796 0.25768 29.23388)'

// Works from any input format — hex, rgb(), hsl(), oklch(), oklab(), plain objects:
colordx('oklch(0.5 0.2 240)').toHex();                     // '#0069c7'
colordx({ r: 255, g: 0, b: 0 }).toHslString();             // 'hsl(0 100% 50%)'

// Chain manipulations — each call returns a new immutable Colordx:
colordx('#ff0000').lighten(0.1).saturate(0.2).toHex();
colordx('#3d7a9f').rotate(30).darken(0.1).toRgbString();

.toHex() and .toRgbString() clip an out-of-gamut oklch() / oklab() the way browsers render it. For gamut mapping instead, see Gamut.

API

All methods are immutable — they return a new Colordx instance.

A method that needs two colors (contrast(), mix(), delta(), over(), nearest(), …) throws a RangeError on an invalid one, naming the method and the input. Single-color methods on an invalid instance read it as black, as colord does, so check isValid() on untrusted input.

Parsing

Accepts any CSS color string or color object:

colordx('#ff0000');
colordx('#f00');
colordx('rgb(255 0 0)');
colordx('color(srgb 1 0 0)'); // CSS Color 4 color() form of sRGB
colordx('rgba(255, 0, 0, 0.5)');
colordx('hsl(0 100% 50%)');
colordx('oklab(0.6279 0.2249 0.1257)');
colordx('oklch(0.6279 0.2577 29.23)');
colordx('oklch(6e-1 1e-7 30)');           // CSS number tokens may carry an exponent
colordx({ r: 255, g: 0, b: 0 });           // alpha defaults to 1
colordx({ r: 255, g: 0, b: 0, alpha: 0.5 });
colordx({ r: 255, g: 0, b: 0, a: 0.5 });   // `a` is an alias for `alpha`
colordx({ h: 0, s: 100, l: 50 });
colordx({ l: 0.6279, a: 0.2249, b: 0.1257 }); // OKLab
colordx({ l: 0.6279, c: 0.2577, h: 29.23 }); // OKLch
// With p3 plugin loaded:
colordx('color(display-p3 0.9175 0.2003 0.1386)'); // Display-P3 string
// With rec2020 plugin loaded:
colordx('color(rec2020 0.82346 0.32843 0.18034)'); // Rec.2020 string
// With a98rgb plugin loaded:
colordx('color(a98-rgb 0.8586 0 0)'); // A98 (Adobe RGB 1998) string
// With prophoto plugin loaded:
colordx('color(prophoto-rgb 0.70225 0.27572 0.10355)'); // ProPhoto string
// With srgb-linear plugin loaded:
colordx('color(srgb-linear 1 0 0)'); // linear-light sRGB string
// With hwb plugin loaded:
colordx('hwb(0 0% 0%)');
colordx({ h: 0, w: 0, b: 0 });
// With hsv plugin loaded:
colordx({ h: 0, s: 100, v: 100 }); // HSV

Channels are clamped the way CSS Color 4 clamps them at parsed-value time: rgb() / hsl() channels to their ranges, lab() / lch() / oklab() / oklch() lightness to [0, 100] / [0, 1], chroma to ≥ 0. Lab/LCH a, b, c and OKLab/OKLCh a, b, c are unbounded, which is what makes out-of-gamut colors representable (see Gamut). Alpha is clamped to [0, 1].

Edge cases: lightness round-trips, rejected objects, NaN
  • An imaginary lab() / lch() / color(xyz …) input can have an OKLab lightness outside [0, 1]; .toOklchString() reports it faithfully, but feeding that string back clamps L, so only colors with L in range round-trip through OKLCh. The same holds the other way for CIE Lab: a far-out-of-gamut oklch() / oklab() / color() input can have a Lab lightness below 0 (oklch(0.31924 0.4 279.2) → lab(-7.33 212.95 -169.91)), which .toLabString() / .toLchString() print as is and a browser clamps to 0.
  • An OKLab/OKLCh object with l > 1 is rejected as invalid — it is almost certainly a CIE Lab/LCH value missing its colorSpace: 'lab' | 'lch' brand, and clamping it to white would hide the mistake.
  • An object whose colorSpace names a different space than its keys ({ r, g, b, colorSpace: 'lab' }, { h, s, l, colorSpace: 'okhsv' }) is rejected rather than read as the unbranded model.
  • NaN in any channel reads as 0 (an infinite hue reads as 0°).

Object input accepts a as an alias for alpha (the { r, g, b, a } shape colord and tinycolor2 use) in every color model except Lab and OKLab, where a is a channel. When both are present, alpha wins.

TypeScript: input color objects use *ColorInput types (alpha optional, defaults to 1). Output methods like .toRgb() / .toOklch() return *Color types (alpha always present).

import type { RgbColor, RgbColorInput } from '@colordx/core';

const input: RgbColorInput = { r: 255, g: 0, b: 0 };       // alpha optional
const output: RgbColor = colordx(input).toRgb();           // alpha guaranteed
Conversion
.toRgb()           // { r: 255, g: 0, b: 0, alpha: 1 }
.toRgb(2)          // unrounded channels, kept to 2 decimals: colordx('hsl(210 40% 47%)').toRgb(2) → { r: 71.91, g: 119.85, b: 167.79, alpha: 1 }
.toRgbString()                    // 'rgb(255 0 0)'                — CSS Color 4 (default)
.toRgbString({ legacy: true })    // 'rgb(255, 0, 0)'              — CSS Color 3 comma syntax
// Legacy form also switches to `rgba()` when alpha < 1:
colordx({ r: 255, g: 0, b: 0, alpha: 0.5 }).toRgbString({ legacy: true }); // 'rgba(255, 0, 0, 0.5)'
.toHex()           // '#ff0000'
.toNumber()        // 16711680  (0xff0000 — PixiJS / Discord integer format)
.toNumber32()      // 4278190335 (0xff0000ff — 32-bit RGBA, alpha as the low byte)
.toHsl()           // { h: 0, s: 100, l: 50, alpha: 1 }
.toHslString()     // 'hsl(0 100% 50%)'
// toHsl accepts an optional precision argument (decimal places):
colordx('#3d7a9f').toHsl()         // { h: 202.65, s: 44.55, l: 43.14, alpha: 1 }      — default (2)
colordx('#3d7a9f').toHsl(4)        // { h: 202.6531, s: 44.5455, l: 43.1373, alpha: 1 }
colordx('#3d7a9f').toHsl(0)        // { h: 203, s: 45, l: 43, alpha: 1 }               — integers
colordx('#3d7a9f').toHslString()   // 'hsl(202.65 44.55% 43.14%)'
colordx('#3d7a9f').toHslString(4)  // 'hsl(202.6531 44.5455% 43.1373%)'
// With hwb plugin loaded:
.toHwb()           // { h: 0, w: 0, b: 0, alpha: 1 }
.toHwbString()     // 'hwb(0 0% 0%)'
.toOklab()         // { l: 0.62796, a: 0.22486, b: 0.12585, alpha: 1 }
.toOklabString()   // 'oklab(0.62796 0.22486 0.12585)'
.toOklch()         // { l: 0.62796, c: 0.25768, h: 29.23388, alpha: 1 }
.toOklchString()   // 'oklch(0.62796 0.25768 29.23388)'
// With p3 plugin loaded:
.toP3()            // { r: 0.9175, g: 0.2003, b: 0.1386, alpha: 1, colorSpace: 'display-p3' }
.toP3String()      // 'color(display-p3 0.9175 0.2003 0.1386)'
// With srgb-linear plugin loaded:
.toSrgbLinear()    // { r: 1, g: 0, b: 0, alpha: 1, colorSpace: 'srgb-linear' }
.toSrgbLinearString() // 'color(srgb-linear 1 0 0)'
Precision

Every toX() / toXString() method accepts an optional precision (decimal places), applied uniformly to every channel of that format. Alpha is fixed at 3 dp globally. Hues are wrapped after rounding, so a hue never prints as 360 at any precision. Format-specific defaults (scale-appropriate):

format default
toHsl, toHsv, toHwb, toCmyk, toLab, toLch, toXyz, toXyzD65 2
toP3, toA98 4
toOklab, toOklch, toOkhsl, toOkhsv, toSrgbLinear, toRec2020, toProphoto, toXyzString, toXyzD65String 5

Each string default is the fewest decimals at which every 8-bit sRGB color parses back to the same bytes. A precision above 20 reads as 20 (past double precision anyway), and NaN or a negative as 0.

colordx('#3d7a9f').toHsl();      // { h: 202.65, s: 44.55, l: 43.14, alpha: 1 }
colordx('#3d7a9f').toHsl(4);     // { h: 202.6531, s: 44.5455, l: 43.1373, alpha: 1 }
colordx('#3d7a9f').toHsl(0);     // { h: 203, s: 45, l: 43, alpha: 1 }

colordx('#ff0000').toOklchString();   // 'oklch(0.62796 0.25768 29.23388)'
colordx('#ff0000').toOklchString(2);  // 'oklch(0.63 0.26 29.23)'
Manipulation
.lighten(0.1)                        // increase lightness by 10 percentage points
.lighten(0.1, { relative: true })    // increase lightness by 10% of current value
.darken(0.1)                         // decrease lightness by 10 percentage points
.darken(0.1, { relative: true })     // decrease lightness by 10% of current value
.saturate(0.1)                       // increase saturation by 10 percentage points
.saturate(0.1, { relative: true })   // increase saturation by 10% of current value
.desaturate(0.1)                     // decrease saturation by 10 percentage points
.desaturate(0.1, { relative: true }) // decrease saturation by 10% of current value
.grayscale()       // fully desaturate
.invert()          // invert RGB channels
.rotate(30)        // rotate hue by 30°
.alpha(0.5)        // set alpha
.hue(120)          // set hue (HSL)
.lightness(0.5)    // set lightness (OKLCH, 0–1)
.chroma(0.1)       // set chroma (OKLCH, 0–0.4)

The HSL-based methods and invert work on the sRGB-clipped color, the one .toHex() prints. lightness() and chroma() work in OKLCH and clip the result. A whole-turn rotate (0, ±360, …) returns the color untouched.

By default, .lighten(0.1) shifts lightness by an absolute 10 percentage points. Pass { relative: true } to shift by a fraction of the current value instead — useful when migrating from Qix's color library or when you want proportional adjustments:

colordx('hsl(0 100% 10%)').lighten(0.1); // l = 10 + 10 = 20%  (absolute)
colordx('hsl(0 100% 10%)').lighten(0.1, { relative: true }); // l = 10 * 1.1 = 11% (relative)

colordx('hsl(0 40% 50%)').saturate(0.1); // s = 40 + 10 = 50%  (absolute)
colordx('hsl(0 40% 50%)').saturate(0.1, { relative: true }); // s = 40 * 1.1 = 44% (relative)

The same flag works on .darken() and .desaturate().

Getters
.isValid()         // true if input was parseable
.alpha()           // get alpha (0–1)
.hue()             // get HSL hue, [0, 360)
.lightness()       // get OKLCH lightness (0–1)
.chroma()          // get OKLCH chroma (0–0.4)
.brightness()      // perceived brightness (0–1), of the sRGB-clipped color
.isDark()          // brightness < 0.5
.isLight()         // brightness >= 0.5
.isEqual('#f00')   // same RGBA bytes after rounding; false if either is invalid
.over('#fff')      // source-over composite of a translucent color onto a bg
// With a11y plugin loaded:
.luminance()       // relative luminance (0–1, WCAG), optional precision
.contrast('#fff')  // WCAG 2.x contrast ratio (1–21), optional precision
// With mix plugin loaded:
.mix('#0000ff', 0.5)       // mix in sRGB space (CSS spec)
.mixOklab('#0000ff', 0.5)  // mix in Oklab space (perceptually uniform)
Utilities
import { getFormat, nearest, random } from '@colordx/core';

getFormat('#ff0000'); // 'hex'
getFormat('rgb(255 0 0)'); // 'rgb'
getFormat('hsl(0 100% 50%)'); // 'hsl'
getFormat('oklch(0.5 0.2 240)'); // 'oklch'
getFormat('oklab(0.6279 0.2249 0.1257)'); // 'oklab'
getFormat({ r: 255, g: 0, b: 0 }); // 'rgb'
getFormat({ h: 0, s: 100, l: 50 }); // 'hsl'
getFormat('notacolor'); // undefined
// Plugin-added parsers register their own format:
// p3 → 'p3', hsv → 'hsv', hwb → 'hwb', okhsl → 'okhsl', okhsv → 'okhsv', cmyk → 'cmyk', lch → 'lch', lab → 'lab' | 'xyz' | 'xyz-d65', names → 'name', rec2020 → 'rec2020', a98rgb → 'a98-rgb', prophoto → 'prophoto-rgb', srgb-linear → 'srgb-linear'

nearest('#800', ['#f00', '#ff0', '#00f']); // '#f00' — perceptual distance via OKLab
nearest('#ffe', ['#f00', '#ff0', '#00f']); // '#ff0'

random(); // random Colordx instance

Gamut

An out-of-gamut oklch() / oklab() input is stored unclamped, so .toOklchString() round-trips what you wrote. .toHex() and .toRgbString() clip it at output the way browsers do, which is what you want for everyday conversion. When you need a Colordx that is itself inside sRGB, choose how it gets there:

const input = 'oklch(0.5 0.4 180)';  // out of sRGB gamut

// 1. Preserve — keep the authored oklch as-is, clip only at sRGB output time
colordx(input).toOklchString();          // 'oklch(0.5 0.4 180)'
colordx(input).toRgbString();            // 'rgb(0 152 108)' — naive clip, matches browser

// 2. Map — CSS Color 4 gamut mapping (reduces chroma; lightness + hue kept within JND)
colordx(input).mapSrgb().toOklchString();   // 'oklch(0.50903 0.09378 177.85846)'
colordx(input).mapSrgb().toRgbString();     // 'rgb(0 119 102)'

// 3. Clamp — naive-clip into sRGB as a Colordx (matches browser, but hue drifts)
colordx(input).clampSrgb().toOklchString(); // 'oklch(0.60125 0.1276 164.29893)'
colordx(input).clampSrgb().toRgbString();   // 'rgb(0 152 108)' — same bytes as (1)
  • .mapSrgb() — CSS Color 4 gamut mapping: reduces chroma and keeps lightness and hue within a just-noticeable difference. Use for CSS output, OKLCH pickers and harmonies.
  • .clampSrgb() — naive clip in linear sRGB. Hue and lightness may drift. Use when you want a Colordx whose .toOklchString() describes what browsers actually render.
How far mapSrgb() can drift, and which methods clip

Per the spec, the search stops at the first clipped color within a just-noticeable difference (deltaEOK 0.02) of its chroma-reduced candidate, so L and h can drift slightly — most visibly on colors far outside sRGB (the example above lands at hue 177.9°, not 180°; a pale or dark saturated red can move several degrees of hue, or land on pure #xx0000). When a palette needs exact L and h per step, bisect chroma with inGamutSrgb() instead.

Which color a method sees follows from its model. Wide-gamut models (toOklab, toOklch, toLab, toLch, toXyz*, toP3, toRec2020, toA98, toProphoto, toSrgbLinear, mix, mixOklab, mixLab, delta) read the unclamped color, and the three mixers keep their result unclamped too, like color-mix(). sRGB-bounded models (toRgb, toHex, toHsl, toHsv, toHwb, toCmyk, toName, brightness) and the HSL-based manipulators read the naive-clipped color, so .toHslString() always names the same color as .toHex(). The a11y and cvd plugins gamut-map (not clip) first.

Colordx.toGamutSrgb(input) is the static form of colordx(input).mapSrgb(). It and the plugin mappers (toGamutP3(), toGamutRec2020(), toGamutA98(), toGamutProphoto()) return a color already in the target gamut unchanged.

colordx also includes standalone utilities for checking and mapping into wider gamuts (Display-P3 / Rec.2020, via plugins):

import { Colordx, inGamutSrgb } from '@colordx/core';
import { inGamutP3 } from '@colordx/core/plugins/p3';
import { inGamutRec2020 } from '@colordx/core/plugins/rec2020';
import p3 from '@colordx/core/plugins/p3';
import rec2020 from '@colordx/core/plugins/rec2020';
extend([p3, rec2020]);

// Check: is this color displayable in sRGB?
inGamutSrgb('#ff0000'); // true  — hex is always sRGB
inGamutSrgb('oklch(0.5 0.1 30)'); // true  — clearly in sRGB
inGamutSrgb('oklch(0.5 0.4 180)'); // false — too much cyan chroma
inGamutSrgb('not-a-color'); // false — not a color, so in no gamut

// Map: reduce chroma until in-gamut (lightness and hue kept within JND)
Colordx.toGamutSrgb('oklch(0.5 0.4 180)'); // → Colordx at the sRGB boundary
Colordx.toGamutSrgb('#ff0000'); // → unchanged, already in sRGB

// Display-P3 gamut (wider than sRGB) — available after extend([p3])
inGamutP3('oklch(0.64 0.27 29)'); // true  — inside P3 but outside sRGB
inGamutP3('oklch(0.5 0.4 180)'); // false — outside P3
Colordx.toGamutP3('oklch(0.5 0.4 180)'); // → Colordx at the P3 boundary

// Rec.2020 gamut (wider than P3) — available after extend([rec2020])
inGamutRec2020('oklch(0.5 0.4 180)'); // false — outside Rec.2020
Colordx.toGamutRec2020('oklch(0.5 0.4 180)'); // → Colordx at the Rec.2020 boundary

inGamut* always returns true for sRGB-bounded inputs (hex, rgb, hsl, hsv, hwb). Mapping follows the CSS Color 4 gamut mapping algorithm.

How the gamuts nest

Gamut containment is largely hierarchical: sRGB ⊂ Display-P3 ⊂ Rec.2020 ⊂ ProPhoto. A98 (Adobe RGB 1998) sits between sRGB and Rec.2020 — wider than sRGB, mostly in the greens — but is not a strict superset of Display-P3.

Checks and mapping accept every wide-gamut input colordx() does — lab() / lch() strings, color(display-p3 …) and friends, branded { colorSpace: 'lab' | 'lch' } objects — once that format's plugin is loaded. A plugin's own helpers (inGamutP3, inGamutRec2020, …) read their own format without extend():

extend([lab, lch]);

// CIE LCH object — recognized as wide-gamut, not clamped at parse time
const lch = { l: 50, c: 100, h: 180, alpha: 1, colorSpace: 'lch' as const };
inGamutSrgb(lch);  // false — outside sRGB
inGamutP3(lch);    // false — outside P3
colordx(lch).toHex();           // '#009774' — naive clip
colordx(lch).mapSrgb().toHex(); // '#008471' — chroma-reduced (CSS Color 4)

// CIE Lab string works too
inGamutSrgb('lab(50 100 0)'); // false
Colordx.toGamutSrgb('lab(50 100 0)'); // → Colordx at the sRGB boundary

Plugins

Opt-in plugins for less common color spaces and utilities. Each is imported from @colordx/core/plugins/<name> and registered once with extend():

Plugin Adds
Color spaces
lab CIE Lab and XYZ (D50, D65), mixLab(), delta() (CIEDE2000)
lch CIE LCH
hsv HSV, plus HSV channel functions
hwb HWB
cmyk CMYK, device-cmyk()
okhsl, okhsv Okhsl and Okhsv picker spaces
p3 Display-P3, inGamutP3(), Colordx.toGamutP3()
rec2020 Rec.2020, inGamutRec2020(), Colordx.toGamutRec2020()
a98rgb A98 (Adobe RGB 1998), inGamutA98(), Colordx.toGamutA98()
prophoto ProPhoto, inGamutProphoto(), Colordx.toGamutProphoto()
srgb-linear color(srgb-linear …)
Tools
a11y WCAG and APCA contrast, readability gates, fixContrast()
cvd Colour vision deficiency simulation
mix mix(), mixOklab(), tints, shades, tones, palettes
harmonies Color harmonies
names CSS color names
minify Shortest CSS string

All of them at once:

import { extend } from '@colordx/core';
import a11y from '@colordx/core/plugins/a11y';
import cmyk from '@colordx/core/plugins/cmyk';
import cvd from '@colordx/core/plugins/cvd';
import harmonies from '@colordx/core/plugins/harmonies';
import hwb from '@colordx/core/plugins/hwb';
import hsv from '@colordx/core/plugins/hsv';
import okhsl from '@colordx/core/plugins/okhsl';
import okhsv from '@colordx/core/plugins/okhsv';
import lab from '@colordx/core/plugins/lab';
import lch from '@colordx/core/plugins/lch';
import minify from '@colordx/core/plugins/minify';
import mix from '@colordx/core/plugins/mix';
import names from '@colordx/core/plugins/names';
import p3 from '@colordx/core/plugins/p3';
import rec2020 from '@colordx/core/plugins/rec2020';
import a98rgb from '@colordx/core/plugins/a98rgb';
import prophoto from '@colordx/core/plugins/prophoto';
import srgbLinear from '@colordx/core/plugins/srgb-linear';

extend([lab, lch, cmyk, names, a11y, harmonies, hwb, hsv, okhsl, okhsv, mix, minify, p3, rec2020, a98rgb, prophoto, srgbLinear]);
lab plugin

CIE Lab (D50), CIE XYZ (D50), and CIE XYZ (D65) color models. Lab and XYZ objects are also accepted as color input (Lab requires a colorSpace: 'lab' discriminant; XYZ D65 requires colorSpace: 'xyz-d65'; plain { x, y, z } parses as D50). Also adds .mixLab() for perceptual mixing in CIE Lab, .delta() for CIEDE2000 color difference, and string conversion methods.

import lab from '@colordx/core/plugins/lab';

extend([lab]);

colordx('#ff0000').toLab(); // { l: 54.29, a: 80.8, b: 69.89, alpha: 1, colorSpace: 'lab' }
colordx('#ff0000').toLabString(); // 'lab(54.29 80.8 69.89)'
colordx('lab(54.29 80.8 69.89)').toHex(); // '#ff0000'  — lab strings are parseable

// XYZ D50 (chromatic-adapted; matches Lab's white point)
colordx('#ff0000').toXyz(); // { x: 43.61, y: 22.25, z: 1.39, alpha: 1 }
colordx('#ff0000').toXyzString(); // 'color(xyz-d50 0.43607 0.22249 0.01392)' — CSS scale (1 = reference white)

// XYZ D65 (screen-native; no Bradford adaptation — same illuminant as sRGB/OKLab)
colordx('#ff0000').toXyzD65(); // { x: 41.24, y: 21.26, z: 1.93, alpha: 1, colorSpace: 'xyz-d65' }
colordx('#ff0000').toXyzD65String(); // 'color(xyz-d65 0.41239 0.21264 0.01933)'

// Lab and XYZ objects parse as color input (with lab plugin loaded)
// Lab objects require colorSpace: 'lab' to distinguish from OKLab (which has the same l/a/b shape)
colordx({ l: 54.29, a: 80.8, b: 69.89, colorSpace: 'lab' as const }).toHex(); // '#ff0000'
colordx({ x: 43.61, y: 22.25, z: 1.39 }).toHex(); // '#ff0000' (D50)
colordx({ x: 41.24, y: 21.26, z: 1.93, colorSpace: 'xyz-d65' as const }).toHex(); // '#ff0000'

// color() strings parse for both white points. Per CSS Color 4 the string channels are 0–1
// (1 = reference-white Y, 100% = 1), unlike the 0–100 object convention above.
colordx('color(xyz-d50 0.43607 0.22249 0.01392)').toHex(); // '#ff0000'
colordx('color(xyz-d65 0.41239 0.21264 0.01933)').toHex(); // '#ff0000'
colordx('color(xyz 0.4124 0.2126 0.0193)').toHex(); // '#ff0000' — bare xyz is the spec alias for xyz-d65
colordx('color(xyz-d65 41.24% 21.26% 1.93%)').toHex(); // '#ff0000'

// Mix in CIE Lab space
colordx('#000000').mixLab('#ffffff').toHex(); // '#777777'

// CIEDE2000 perceptual color difference (0 = identical, ~1 = maximum)
colordx('#ff0000').delta('#ff0000'); // 0
colordx('#000000').delta('#ffffff'); // ~1
colordx('#ff0000').delta(); // compared against white (default)
colordx('#ef4444').delta('#10b981', 5); // 0.71465 — optional precision (default 3)
lch plugin

CIE LCH (D50) — the polar form of CIE Lab. Parses lch() CSS strings and LCH objects.

import lch from '@colordx/core/plugins/lch';

extend([lch]);

colordx('#ff0000').toLch(); // { l: 54.29, c: 106.84, h: 40.86, alpha: 1, colorSpace: 'lch' }
colordx('#ff0000').toLchString(); // 'lch(54.29 106.84 40.86)'
colordx('lch(54.29 106.84 40.86)').toHex(); // '#ff0000'
// LCH objects require colorSpace: 'lch' to distinguish from OKLCH (which has the same l/c/h shape)
colordx({ l: 50, c: 50, h: 180, colorSpace: 'lch' as const }).toHex(); // parses as LCH object
hsv plugin

HSV/HSVa color model. Parses hsv() / hsva() strings and HSV objects.

import hsv from '@colordx/core/plugins/hsv';

extend([hsv]);

colordx('#ff0000').toHsv(); // { h: 0, s: 100, v: 100, alpha: 1 }
colordx('#ff0000').toHsvString(); // 'hsv(0 100% 100%)'
colordx('hsv(0 100% 100%)').toHex(); // '#ff0000'
colordx({ h: 0, s: 100, v: 100, alpha: 1 }).toHex(); // '#ff0000'

The plugin entry also exports the HSV channel functions — rgbToHsvChannels, hsvToRgbChannels and their *Into siblings — for per-pixel work such as a saturation/value plane:

import { hsvToRgbChannelsInto } from '@colordx/core/plugins/hsv';

// 256×256 S/V plane for a fixed hue → RGBA bytes. One buffer, no allocation per pixel.
const buf = new Float64Array(3);
const plane = new Uint8ClampedArray(256 * 256 * 4);
let i = 0;
for (let y = 0; y < 256; y++) {
  for (let x = 0; x < 256; x++) {
    hsvToRgbChannelsInto(buf, 210, (x / 255) * 100, (1 - y / 255) * 100);
    plane[i++] = buf[0] * 255;
    plane[i++] = buf[1] * 255;
    plane[i++] = buf[2] * 255;
    plane[i++] = 255;
  }
}
hwb plugin

CSS Color Level 4 HWB (Hue, Whiteness, Blackness) color model.

import hwb from '@colordx/core/plugins/hwb';

extend([hwb]);

colordx('#ff0000').toHwb();         // { h: 0, w: 0, b: 0, alpha: 1 }
colordx('#ff0000').toHwbString();   // 'hwb(0 0% 0%)'
colordx('hwb(0 0% 0%)').toHex();   // '#ff0000'
colordx({ h: 0, w: 0, b: 0, alpha: 1 }).toHex(); // '#ff0000'

// toHwb accepts an optional precision argument (decimal places):
colordx('#3d7a9f').toHwb();    // { h: 202.65, w: 23.92, b: 37.65, alpha: 1 }   — default (2)
colordx('#3d7a9f').toHwb(2);   // { h: 202.65, w: 23.92, b: 37.65, alpha: 1 }
colordx('#3d7a9f').toHwbString();  // 'hwb(202.65 23.92% 37.65%)'
colordx('#3d7a9f').toHwbString(2); // 'hwb(202.65 23.92% 37.65%)'
cmyk plugin

CMYK color model. Parses device-cmyk() CSS strings and CMYK objects.

import cmyk from '@colordx/core/plugins/cmyk';

extend([cmyk]);

colordx('#ff0000').toCmyk(); // { c: 0, m: 100, y: 100, k: 0, alpha: 1 }
colordx('#ff0000').toCmykString(); // 'device-cmyk(0% 100% 100% 0%)'
colordx('device-cmyk(0% 100% 100% 0%)').toHex(); // '#ff0000'
colordx({ c: 0, m: 100, y: 100, k: 0 }).toHex(); // '#ff0000'
okhsl and okhsv plugins

Okhsl and Okhsv are Björn Ottosson's color-picker spaces: the shape of HSL / HSV, where every h/s/l combination is a real sRGB color, with the perceptual behaviour of OKLCH. Same scale as toHsl() / toHsv(): h in degrees, s / l / v in 0–100. The okhsl() / okhsv() strings are this library's own (CSS has no such syntax), and objects carry a colorSpace brand because { h, s, l } alone is HSL.

import okhsl from '@colordx/core/plugins/okhsl';
import okhsv from '@colordx/core/plugins/okhsv';

extend([okhsl, okhsv]);

colordx('#3d7a9f').toOkhsl();       // { h: 237.65615, s: 57.92201, l: 48.38305, alpha: 1, colorSpace: 'okhsl' }
colordx('#3d7a9f').toOkhsv();       // { h: 237.65615, s: 65.38077, v: 64.31605, alpha: 1, colorSpace: 'okhsv' }
colordx('#3d7a9f').toOkhslString(); // 'okhsl(237.65615 57.92201% 48.38305%)'
colordx('#3d7a9f').toOkhsvString(); // 'okhsv(237.65615 65.38077% 64.31605%)'
colordx('okhsl(237.65615 57.92201% 48.38305%)').toHex(); // '#3d7a9f'
colordx({ colorSpace: 'okhsv', h: 237.65615, s: 65.38077, v: 64.31605 }).toHex(); // '#3d7a9f'
colordx('#3d7a9f').toOkhsl(2);      // { h: 237.66, s: 57.92, l: 48.38, alpha: 1, colorSpace: 'okhsl' }
colordx('#3d7a9f').toOkhsl().h === colordx('#3d7a9f').toOklch().h; // true
Channels, precision and edge cases

h is exactly the OKLCH hue, so a hue ramp stays the same hue instead of drifting purple in the blues; Okhsl's l is a lightness estimate that tracks CIELab L (toe(OKLab L)); s remaps chroma onto 0–100 with the interior kept smooth across hues.

Both spaces are defined for sRGB only; a wide-gamut input reads as its sRGB clip, like toHsl(). The default precision is 5 dp rather than the 2 of toHsl(): the OKLCH hue is sensitive around the edges of the sRGB cube, and at 2 dp rgb(0, 42, 204) comes back as rgb(7, 53, 190). Two edge behaviours are inherent to the spaces and shared with every implementation of the reference code: pure blues (r = g = 0) do not round-trip at any printed precision (#0000ff → okhsl(264.05202 100% 36.65653%) → #0134e2 — at that hue and lightness the most chromatic sRGB color really is that one; the hue would need ~8 dp), and the reference approximates the gamut cusp with a polynomial, so ~0.7% of 8-bit colors report a raw s a little above 100 (at most ~101.2). toOkhsl() / toOkhsv() clamp it, the channel functions do not, and about 1 in 16 of the clamped colors comes back up to 5 bytes off.

The plugin entries also export channel functions — rgbToOkhslChannels, okhslToRgbChannels, rgbToOkhsvChannels, okhsvToRgbChannels and their *Into siblings — for per-pixel work. Same shape as the HSV ones (RGB in 0–1), so a picker plane is a drop-in:

import { okhslToRgbChannelsInto } from '@colordx/core/plugins/okhsl';

// 256×256 saturation / lightness plane for a fixed hue → RGBA bytes. Every cell is in gamut.
const buf = new Float64Array(3);
const plane = new Uint8ClampedArray(256 * 256 * 4);
let i = 0;
for (let y = 0; y < 256; y++) {
  for (let x = 0; x < 256; x++) {
    okhslToRgbChannelsInto(buf, 210, (x / 255) * 100, (1 - y / 255) * 100);
    plane[i++] = buf[0] * 255;
    plane[i++] = buf[1] * 255;
    plane[i++] = buf[2] * 255;
    plane[i++] = 255;
  }
}
p3 plugin

Adds Display-P3 color space support. P3 has a wider gamut than sRGB and is natively supported by all modern browsers and most Mac/iOS displays.

import p3 from '@colordx/core/plugins/p3';

extend([p3]);

colordx('#ff0000').toP3(); // { r: 0.9175, g: 0.2003, b: 0.1386, alpha: 1, colorSpace: 'display-p3' }
colordx('#ff0000').toP3String(); // 'color(display-p3 0.9175 0.2003 0.1386)'

// Parse Display-P3 strings (alpha optional)
colordx('color(display-p3 0.9175 0.2003 0.1386)').toHex(); // '#ff0000'
colordx('color(display-p3 0.9175 0.2003 0.1386 / 0.5)').toHex(); // '#ff000080'

The plugin also exports standalone gamut utilities and low-level channel functions. inGamutP3 and the channel helpers need no extend(). Gamut mapping is available as Colordx.toGamutP3 after extend([p3]):

import { Colordx, extend } from '@colordx/core';
import p3, { inGamutP3, linearToP3Channels, oklchToP3Channels } from '@colordx/core/plugins/p3';

extend([p3]);

inGamutP3('oklch(0.64 0.27 29)');        // true — inside P3 but outside sRGB
Colordx.toGamutP3('oklch(0.5 0.4 180)'); // → Colordx at the P3 boundary

oklchToP3Channels(0.5, 0.2, 240); // [r, g, b] gamma-encoded P3, unclamped

Object parsing is also supported using the colorSpace discriminant:

colordx({ r: 0.9505, g: 0.2856, b: 0.0459, alpha: 1, colorSpace: 'display-p3' }).toHex();
rec2020 plugin

Adds Rec.2020 (BT.2020) color space support. Rec.2020 is wider than Display-P3 and covers most of the visible spectrum. It uses the pure 2.4 gamma CSS Color 4 now defines for rec2020 (csswg-drafts#12574), so color(rec2020 …) values differ from those of libraries still on the older BT.2020 curve (culori 4, for one).

import rec2020 from '@colordx/core/plugins/rec2020';

extend([rec2020]);

colordx('#ff0000').toRec2020(); // { r: 0.82346, g: 0.32843, b: 0.18034, alpha: 1, colorSpace: 'rec2020' }
colordx('#ff0000').toRec2020String(); // 'color(rec2020 0.82346 0.32843 0.18034)'

// Parse Rec.2020 strings (alpha optional)
colordx('color(rec2020 0.82346 0.32843 0.18034)').toHex(); // '#ff0000'
colordx('color(rec2020 0.82346 0.32843 0.18034 / 0.5)').toHex(); // '#ff000080'

The plugin also exports standalone gamut utilities and low-level channel functions. inGamutRec2020 and the channel helpers need no extend(). Gamut mapping is available as Colordx.toGamutRec2020 after extend([rec2020]):

import { Colordx, extend } from '@colordx/core';
import rec2020, { inGamutRec2020, linearToRec2020Channels, oklchToRec2020Channels } from '@colordx/core/plugins/rec2020';

extend([rec2020]);

inGamutRec2020('oklch(0.5 0.4 180)');        // false — outside Rec.2020
Colordx.toGamutRec2020('oklch(0.5 0.4 180)'); // → Colordx at the Rec.2020 boundary

oklchToRec2020Channels(0.5, 0.2, 240); // [r, g, b] gamma-encoded Rec.2020, unclamped

Object parsing is also supported using the colorSpace discriminant:

colordx({ r: 0.82346, g: 0.32843, b: 0.18034, alpha: 1, colorSpace: 'rec2020' }).toHex();
a98rgb plugin

Adds A98 (Adobe RGB 1998) color space support. A98 shares sRGB's red and blue primaries but a wider green primary, so it extends mainly into the greens/cyans. It is a CSS Color 4 predefined space and a long-standing photography/print working space.

import a98rgb from '@colordx/core/plugins/a98rgb';

extend([a98rgb]);

colordx('#ff0000').toA98(); // { r: 0.8586, g: 0, b: 0, alpha: 1, colorSpace: 'a98-rgb' }
colordx('#ff0000').toA98String(); // 'color(a98-rgb 0.8586 0 0)'

// Parse A98 strings (alpha optional)
colordx('color(a98-rgb 0.8586 0 0)').toHex(); // '#ff0000'
colordx('color(a98-rgb 0.8586 0 0 / 0.5)').toHex(); // '#ff000080'

The plugin also exports standalone gamut utilities and low-level channel functions. inGamutA98 and the channel helpers need no extend(). Gamut mapping is available as Colordx.toGamutA98 after extend([a98rgb]):

import { Colordx, extend } from '@colordx/core';
import a98rgb, { inGamutA98, linearToA98Channels, oklchToA98Channels } from '@colordx/core/plugins/a98rgb';

extend([a98rgb]);

inGamutA98('oklch(0.7 0.25 150)');        // true — inside A98 but outside sRGB
Colordx.toGamutA98('oklch(0.5 0.4 180)'); // → Colordx at the A98 boundary

oklchToA98Channels(0.5, 0.2, 240); // [r, g, b] gamma-encoded A98, unclamped

Object parsing is also supported using the colorSpace discriminant:

colordx({ r: 0.8586, g: 0, b: 0, alpha: 1, colorSpace: 'a98-rgb' }).toHex();
prophoto plugin

Adds ProPhoto (ROMM RGB) color space support. ProPhoto has an extremely wide gamut — wider than Rec.2020, covering most of the visible spectrum — and uses a D50 white point with a gamma-1.8 transfer curve. It is the standard working space for high-end RAW photo and print pipelines.

import prophoto from '@colordx/core/plugins/prophoto';

extend([prophoto]);

colordx('#ff0000').toProphoto(); // { r: 0.70225, g: 0.27572, b: 0.10355, alpha: 1, colorSpace: 'prophoto-rgb' }
colordx('#ff0000').toProphotoString(); // 'color(prophoto-rgb 0.70225 0.27572 0.10355)'

// Parse ProPhoto strings (alpha optional)
colordx('color(prophoto-rgb 0.70225 0.27572 0.10355)').toHex(); // '#ff0000'
colordx('color(prophoto-rgb 0.70225 0.27572 0.10355 / 0.5)').toHex(); // '#ff000080'

The plugin also exports standalone gamut utilities and low-level channel functions. inGamutProphoto and the channel helpers need no extend(). Gamut mapping is available as Colordx.toGamutProphoto after extend([prophoto]):

import { Colordx, extend } from '@colordx/core';
import prophoto, { inGamutProphoto, linearToProphotoChannels, oklchToProphotoChannels } from '@colordx/core/plugins/prophoto';

extend([prophoto]);

inGamutProphoto('oklch(0.5 0.4 180)');       // false — outside even ProPhoto
Colordx.toGamutProphoto('oklch(0.5 0.4 180)'); // → Colordx at the ProPhoto boundary

oklchToProphotoChannels(0.5, 0.2, 240); // [r, g, b] gamma-encoded ProPhoto, unclamped

Object parsing is also supported using the colorSpace discriminant:

colordx({ r: 0.70225, g: 0.27572, b: 0.10355, alpha: 1, colorSpace: 'prophoto-rgb' }).toHex();
srgb-linear plugin

Adds the CSS Color 4 srgb-linear space: sRGB with the transfer curve removed. Same primaries and gamut as sRGB, so inGamutSrgb() and Colordx.toGamutSrgb() already apply. Values outside [0, 1] are kept as-is and only clip on sRGB output.

import srgbLinear from '@colordx/core/plugins/srgb-linear';

extend([srgbLinear]);

colordx('#3b82f6').toSrgbLinear(); // { r: 0.04374, g: 0.22323, b: 0.92158, alpha: 1, colorSpace: 'srgb-linear' }
colordx('#3b82f6').toSrgbLinearString(); // 'color(srgb-linear 0.04374 0.22323 0.92158)'
colordx('#3b82f6').toSrgbLinearString(4); // 'color(srgb-linear 0.0437 0.2232 0.9216)'

// Parse srgb-linear strings (alpha optional)
colordx('color(srgb-linear 1 0 0)').toHex(); // '#ff0000'
colordx('color(srgb-linear 100% none 0 / 0.5)').toHex(); // '#ff000080'

// Out-of-gamut values survive until sRGB output
colordx('oklch(0.7 0.3 150)').toSrgbLinearString(); // 'color(srgb-linear -0.1728 0.59844 -0.00771)'
inGamutSrgb('color(srgb-linear 1.2 0 0)'); // false

Object parsing is also supported using the colorSpace discriminant:

colordx({ r: 0.5, g: 0, b: 0, colorSpace: 'srgb-linear' }).toHex(); // '#bc0000'

For per-pixel work use the core channel helpers instead: rgbToLinear() and oklchToLinear() return the same linear channels without a Colordx instance.

a11y plugin

WCAG 2.x contrast:

import a11y from '@colordx/core/plugins/a11y';

extend([a11y]);

colordx('#000').isReadable('#fff'); // true  — AA normal (ratio >= 4.5)
colordx('#000').isReadable('#fff', { level: 'AAA' }); // true  — AAA normal (ratio >= 7)
colordx('#000').isReadable('#fff', { size: 'large' }); // true  — AA large (ratio >= 3)
colordx('#000').readableScore('#fff'); // 'AAA'
colordx('#e60000').readableScore('#ffff47'); // 'AA'
colordx('#949494').readableScore('#fff'); // 'AA large'
colordx('#aaa').readableScore('#fff'); // 'fail'
colordx('#777').minReadable('#fff'); // fixContrast at 4.5, or the input if nothing passes

fixContrast finds the nearest color that passes a gate. It keeps hue, moves OKLCH lightness, lets the gamut map reduce chroma only when it must, and returns null when no color with that hue passes. A darker fg stays darker (APCA sign is kept) unless nothing on that side passes.

colordx('#3b82f6').fixContrast('#fff'); // #2c72e5 — WCAG 4.5 by default
colordx('#3b82f6').fixContrast('#fff', { apca: 75 }); // APCA only, |Lc| >= 75
colordx('#3b82f6').fixContrast('#fff', { wcag: 4.5, apca: 75 }); // #2168da — both
colordx('#999').fixContrast('#777', { wcag: 7 }); // null — no grey reaches 7:1 on #777
colordx('oklch(0.9 0.3 145)').fixContrast('#fff', { space: 'p3' }); // fix stays inside Display-P3

Numbers are rounded for display only (contrast to 2 decimals, apcaContrast to 1). Every isReadable* / readableScore gate uses the unrounded value, so a true 4.496:1 fails AA even though contrast() shows 4.5. Pass a precision to see more digits:

colordx('#d200d2').contrast('#fff'); // 4.5
colordx('#d200d2').contrast('#fff', 4); // 4.4959
colordx('#d200d2').isReadable('#fff'); // false

A translucent fg is composited over the bg first. To check a stack (fg over a translucent surface over the page), flatten it bottom up with over():

const surface = colordx('rgba(255, 255, 255, 0.2)').over('#000');
colordx('rgba(255, 255, 255, 0.6)').over(surface).contrast('#000');

Colors outside sRGB are gamut-mapped (not clipped) before the check. WCAG always runs on the sRGB-mapped color; APCA can run on the Display-P3-mapped color with its own coefficients via { space: 'p3' }.

APCA (Accessible Perceptual Contrast Algorithm) — the projected replacement for WCAG 2.x in WCAG 3.0:

// Returns a signed Lc value: positive = dark text on light bg, negative = light text on dark bg
colordx('#000').apcaContrast('#fff'); //  106.0
colordx('#fff').apcaContrast('#000'); // -107.9
colordx('#202122').apcaContrast('#cf674a'); //  37.2  ← dark text on orange
colordx('#ffffff').apcaContrast('#cf674a'); // -69.5  ← white text on orange

// Checks readability using |Lc| thresholds: >= 75 for normal text, >= 60 for large text/headings
colordx('#000').isReadableApca('#fff'); // true
colordx('#777').isReadableApca('#fff'); // false
colordx('#777').isReadableApca('#fff', { size: 'large' }); // true

// Options: precision (default 1) and space ('srgb' | 'p3', default 'srgb')
colordx('#9900ff').apcaContrast('#fff', { precision: 3 }); // 74.956
colordx('oklch(0.8 0.3 145)').apcaContrast('#000', { space: 'p3' }); // -73.3 (P3-mapped), vs -74.8 in sRGB
colordx('#777').isReadableApca('#fff', { space: 'p3' }); // false

APCA is better suited than WCAG 2.x for dark color pairs and more accurately reflects human perception. See Introduction to APCA for background.

cvd plugin

Simulates colour vision deficiency. Machado 2009 (severity 1.0) for protanopia and deuteranopia, the same matrices Chrome and Firefox DevTools use; Brettel 1997 for tritanopia, where Machado is weak. Runs on linear sRGB; wide-gamut input is gamut-mapped first. Reference values are checked against DaltonLens in the test suite.

import cvd from '@colordx/core/plugins/cvd';

extend([cvd]);

colordx('#ff0000').simulate('protanopia'); // #6d5f00
colordx('#ff0000').simulate('deuteranopia'); // #a39000
colordx('#0000ff').simulate('tritanopia'); // #006087

// Do two status colours collapse? Compare ΔE2000 before and after (lab plugin).
const [a, b] = [colordx('#ef4444'), colordx('#10b981')];
a.delta(b, 5); // 0.71465 — clearly distinct
a.simulate('deuteranopia').delta(b.simulate('deuteranopia'), 5); // ~0.14 — nearly the same colour
mix plugin

Color mixing helpers built on top of .mix().

import mix from '@colordx/core/plugins/mix';

extend([mix]);

colordx('#ff0000').tints(5); // [#ff0000, #ff4040, #ff8080, #ffbfbf, #ffffff]
colordx('#ff0000').shades(3); // [#ff0000, #800000, #000000]
colordx('#ff0000').tones(3);  // [#ff0000, #c04040, #808080]

// palette: N evenly-spaced stops toward any target (default: white)
colordx('#ff0000').palette(3, '#0000ff'); // [#ff0000, #800080, #0000ff]
// A fractional count is floored; 0, a negative count or NaN give []; Infinity throws a RangeError.

mix() interpolates in sRGB, matching CSS color-mix(in srgb, ...) and how browsers composite layers. Like color-mix(), it keeps the exact result rather than rounding it to bytes, and a wide-gamut input stays wide-gamut. Use mixOklab() for perceptually uniform blending, or mixLab() (lab plugin) for CIE Lab. All three premultiply by alpha as color-mix() does, so a transparent color contributes no hue: colordx('rgba(255,0,0,0)').mix('#0000ff').toRgbString() is 'rgb(0 0 255 / 0.5)'.

colordx('#000000').mix('#ffffff').toHex();       // '#808080' — sRGB (CSS spec)
colordx('#000000').mixOklab('#ffffff').toHex();  // '#636363' — Oklab (perceptually uniform)

import lab from '@colordx/core/plugins/lab';
extend([lab]);
colordx('#000000').mixLab('#ffffff').toHex();    // '#777777' — CIE Lab

tints(), shades(), tones() and palette() are built on mix(), so they blend in sRGB too.

harmonies plugin

Color harmony generation using hue rotation. Every harmony includes a 0° entry, which is the input color itself — returned unchanged, so a wide-gamut input survives in its own harmony set.

import harmonies from '@colordx/core/plugins/harmonies';

extend([harmonies]);

colordx('#ff0000').harmonies();                              // complementary (default) — 2 colors
colordx('#ff0000').harmonies('complementary');               // [0°, 180°] — 2 colors
colordx('#ff0000').harmonies('analogous');                   // [−30°, 0°, 30°] — 3 colors
colordx('#ff0000').harmonies('split-complementary');         // [0°, 150°, 210°] — 3 colors
colordx('#ff0000').harmonies('triadic');                     // [0°, 120°, 240°] — 3 colors
colordx('#ff0000').harmonies('tetradic');                    // [0°, 90°, 180°, 270°] — 4 colors (square)
colordx('#ff0000').harmonies('rectangle');                   // [0°, 60°, 180°, 240°] — 4 colors
colordx('#ff0000').harmonies('double-split-complementary');  // [−30°, 0°, 30°, 150°, 210°] — 5 colors

An unknown type throws a RangeError naming it.

names plugin

CSS named color support (all 148 names from the CSS spec). toName() returns undefined for colors with no CSS name.

import names from '@colordx/core/plugins/names';

extend([names]);

colordx('red').toHex(); // '#ff0000'
colordx('rebeccapurple').toHex(); // '#663399'
colordx('#ff0000').toName(); // 'red'
colordx('#c06060').toName(); // undefined — no CSS name for this color
colordx('#c06060').toName({ closest: true }); // nearest named color by RGB distance
minify plugin

Returns the shortest valid CSS representation of a color. By default tries hex, RGB, and HSL and picks the shortest.

A color outside sRGB (a wide-gamut oklch(), oklab(), lab(), lch() or color() input) has no hex, RGB, HSL or name form — each would clip it, while a wide-gamut display renders the original — so it minifies to its oklch() string instead, with leading zeros dropped: oklch(0.5 0.2 240) → 'oklch(.5 .2 240)'.

import minify from '@colordx/core/plugins/minify';

extend([minify]);

colordx('#ff0000').minify(); // '#f00'
colordx('#ffffff').minify(); // '#fff'
colordx('#ff0000').minify({ name: true }); // 'red'  — requires names plugin
colordx({ r: 0, g: 0, b: 0, alpha: 0 }).minify({ transparent: true }); // 'transparent'
colordx({ r: 255, g: 0, b: 0, alpha: 0.5 }).minify({ alphaHex: true }); // '#ff000080'

// Disable specific formats to exclude them from candidates:
colordx('#ff0000').minify({ hsl: false }); // skips HSL, picks from hex/RGB (a preference: see the half-byte note below)
Rounding and wide-gamut edge cases

The minify() plugin preserves full HSL precision when building candidates, so minification is lossless — it only picks HSL when the string is genuinely shorter than hex/rgb. One exception: a color with a channel on an inexact half byte (hsl(220, 80%, 50%) has a green of 93.50000000000013) stays hsl() as written, even with hsl: false, because engines round such a channel either way — Chrome paints that color as rgb(26 93 230), exact rounding gives rgb(26 94 230) — and hex, rgb() or a name would commit to one side. When no hsl() reproduces the channels exactly (most hwb() sources), they are printed as fractional legacy rgb() instead, the tie left for the engine. An exact tie (rgb(50% 50% 50%), hsl(270 100% 50%)) comes from exact arithmetic, rounds up in every engine per WPT, and minifies to bytes as usual. Colors outside sRGB are never clipped to hex; they stay oklch(), or color(srgb …) when brighter than white or darker than black, since oklch() clamps its lightness at parse time. With every format option turned off, a visible alpha that hex would round to 00 falls back to rgba().

Channel functions

Plain-number converters for per-pixel work (canvas renderers, pickers, gradients): no parsing, no Colordx object, no rounding.

import { oklchToLinear, oklchToRgbChannels } from '@colordx/core';

oklchToRgbChannels(0.5, 0.2, 240); // [-0.29354, 0.41025, 0.78055] — gamma-encoded sRGB, unclamped
// Out-of-gamut channels leave [0, 1] (this one is outside sRGB) — callers clamp before byte encoding

const linear = oklchToLinear(0.5, 0.2, 240); // unclamped linear sRGB — also a free sRGB gamut check

// Non-OKLCH inputs → linear sRGB (same output scale and gamut-check behavior as oklchToLinear).
// Use these when you already have RGB/Lab/LCH values and want linear pixels without round-tripping through OKLCH.
import {
  labToLinearAndSrgb,
  labToLinearSrgb,
  labToRgbChannels,
  lchToLinearAndSrgb,
  lchToLinearSrgb,
  lchToRgbChannels,
  rgbToLinear,
} from '@colordx/core';

rgbToLinear(1, 0, 0);          // [1, 0, 0]           — 0–1 gamma sRGB in
labToLinearSrgb(54.29, 80.8, 69.89); // Lab D50 → linear sRGB (via XYZ D50)
lchToLinearSrgb(54.29, 106.84, 40.86); // LCH D50 → Lab → linear sRGB

// Gamma-encoded sRGB in one call:
labToRgbChannels(54.29, 80.8, 69.89); // → [r, g, b] gamma sRGB in [0, 1]
lchToRgbChannels(54.29, 106.84, 40.86);

// Both linear (for gamut check) and gamma (for display) in a single pass:
const [lin, srgb] = labToLinearAndSrgb(54.29, 80.8, 69.89); // or lchToLinearAndSrgb

// Hex/RGB input? Parse once, then divide by 255:
const { r, g, b } = colordx('#ff0000').toRgb();
rgbToLinear(r / 255, g / 255, b / 255); // [1, 0, 0]

// P3/Rec.2020 channel functions live in their plugins:
import { labToP3Channels, lchToP3Channels, linearToP3Channels, oklchToP3Channels } from '@colordx/core/plugins/p3';
import {
  labToRec2020Channels,
  lchToRec2020Channels,
  linearToRec2020Channels,
  oklchToRec2020Channels,
} from '@colordx/core/plugins/rec2020';

oklchToP3Channels(0.5, 0.2, 240);      // [r, g, b] gamma-encoded Display-P3, unclamped
oklchToRec2020Channels(0.5, 0.2, 240); // [r, g, b] gamma-encoded Rec.2020, unclamped (2.4 gamma)

// CIE Lab/LCH → P3 / Rec.2020 (hot path for LCH renderers without OKLCH detour):
labToP3Channels(54.29, 80.8, 69.89);      // [r, g, b] gamma P3
lchToP3Channels(54.29, 106.84, 40.86);
labToRec2020Channels(54.29, 80.8, 69.89); // [r, g, b] gamma Rec.2020
lchToRec2020Channels(54.29, 106.84, 40.86);

// Split-step API: compute the shared expensive OKLCH→linear sRGB step once,
// then apply cheap per-space steps to avoid repeating 3× Math.cbrt + OKLab matrix.
linearToP3Channels(...linear);      // linear sRGB → gamma-encoded P3
linearToRec2020Channels(...linear); // linear sRGB → gamma-encoded Rec.2020 (2.4 gamma)

// sRGB ↔ HSL / HSV on the same scale toHsl() / toHsv() report: h in degrees, s and l/v in 0–100.
// RGB is 0–1 gamma sRGB, like rgbToLinear. This is the per-pixel path for pickers, hue wheels
// and vectorscopes — no parsing, no object, no rounding.
import { hslToRgbChannels, rgbToHslChannels } from '@colordx/core';
import { hsvToRgbChannels, rgbToHsvChannels } from '@colordx/core/plugins/hsv';

rgbToHslChannels(1, 0, 0);       // [0, 100, 50]
rgbToHsvChannels(0, 0, 1);       // [240, 100, 100]
hslToRgbChannels(120, 100, 50);  // [0, 1, 0]
hsvToRgbChannels(-30, 50, 50);   // hue wraps: same as hsvToRgbChannels(330, 50, 50)
// Greys report h = 0, s = 0 (same ACHROMATIC_EPS threshold as toHsl()). Nothing is clipped:
// HSL/HSV live on the sRGB cube, so clip a wide-gamut color first — for bytes, pass r/255, g/255, b/255.
Zero-allocation variants

Every channel function has an *Into sibling that writes into a caller-provided Float64Array | number[] instead of allocating a new tuple, for loops that run once per pixel. Output is bit-for-bit identical to the allocating version.

import {
  oklchToLinearInto,
  oklchToRgbChannelsInto,
  oklchToLinearAndSrgbInto,
} from '@colordx/core';
import { linearToP3ChannelsInto, oklchToP3ChannelsInto } from '@colordx/core/plugins/p3';
import { linearToRec2020ChannelsInto, oklchToRec2020ChannelsInto } from '@colordx/core/plugins/rec2020';

// Pixel-renderer pattern: allocate one buffer, reuse for every pixel.
const buf = new Float64Array(3);
for (let y = 0; y < height; y++) {
  for (let x = 0; x < width; x++) {
    const [l, c, h] = getOklch(x, y);
    oklchToP3ChannelsInto(buf, l, c, h);
    imageData[i++] = Math.floor(buf[0] * 255);
    imageData[i++] = Math.floor(buf[1] * 255);
    imageData[i++] = Math.floor(buf[2] * 255);
    imageData[i++] = 255;
  }
}
Full *Into list (all tree-shakable)
// from '@colordx/core' — OKLCH → linear / sRGB
oklchToLinearInto(out, l, c, h);           // → [lr, lg, lb] linear sRGB
oklchToRgbChannelsInto(out, l, c, h);      // → [r, g, b] gamma-encoded sRGB
oklchToLinearAndSrgbInto(linOut, srgbOut, l, c, h); // both at once (distinct buffers)

// from '@colordx/core' — non-OKLCH inputs → linear / gamma sRGB (complements oklchToLinear)
rgbToLinearInto(out, r, g, b);             // 0–1 gamma sRGB → linear sRGB
labToLinearSrgbInto(out, l, a, b);         // CIE Lab (D50) → linear sRGB (via XYZ D50)
labToRgbChannelsInto(out, l, a, b);        // CIE Lab (D50) → gamma sRGB
labToLinearAndSrgbInto(linOut, srgbOut, l, a, b); // both (distinct buffers)
lchToLinearSrgbInto(out, l, c, h);         // CIE LCH (D50) → linear sRGB
lchToRgbChannelsInto(out, l, c, h);        // CIE LCH (D50) → gamma sRGB
lchToLinearAndSrgbInto(linOut, srgbOut, l, c, h);

// from '@colordx/core' — sRGB ↔ HSL (h degrees, s/l 0–100; RGB 0–1)
rgbToHslChannelsInto(out, r, g, b);        // → [h, s, l]
hslToRgbChannelsInto(out, h, s, l);        // → [r, g, b] gamma sRGB

// from '@colordx/core/plugins/hsv' — sRGB ↔ HSV (h degrees, s/v 0–100; RGB 0–1)
rgbToHsvChannelsInto(out, r, g, b);        // → [h, s, v]
hsvToRgbChannelsInto(out, h, s, v);        // → [r, g, b] gamma sRGB

// from '@colordx/core/plugins/p3'
linearToP3ChannelsInto(out, lr, lg, lb);
oklchToP3ChannelsInto(out, l, c, h);
labToP3ChannelsInto(out, l, a, b);
lchToP3ChannelsInto(out, l, c, h);

// from '@colordx/core/plugins/rec2020'
linearToRec2020ChannelsInto(out, lr, lg, lb);
oklchToRec2020ChannelsInto(out, l, c, h);
labToRec2020ChannelsInto(out, l, a, b);
lchToRec2020ChannelsInto(out, l, c, h);

Guidance:

  • Use Float64Array(3) for the buffer when you can — it's the convention and keeps the V8 call site monomorphic. number[] also works.
  • One buffer per loop is plenty; don't allocate per iteration.
  • linOut and srgbOut in oklchToLinearAndSrgbInto must be distinct buffers (the function writes to both).
  • If you're outside a hot loop, the regular allocating versions are more ergonomic — reach for *Into only when you've profiled and GC is the bottleneck.

Functional API

@colordx/core/fn exports the parsers and converters behind colordx() as plain functions. Nothing is shared with the Colordx class, so a bundle only carries what it imports — parsing a hex string and printing it back is about 0.7 KB gzipped.

import { parse, parseHex, rgbToHex, rgbToOklch } from '@colordx/core/fn';

parse('oklch(0.6279 0.2577 29.23)'); // { r, g, b, alpha } or null — every format colordx() accepts, plugins included
parseHex('#ff0000');                 // { r: 255, g: 0, b: 0, alpha: 1 }
rgbToHex({ r: 255, g: 0, b: 0, alpha: 0.5 }); // '#ff000080'
rgbToOklch(parseHex('#ff0000')!);    // { l: 0.6279…, c: 0.2576…, h: 29.23…, alpha: 1 } — unrounded

RgbColor here is the library's storage form: r, g, b on the 0–255 scale, unrounded and unclamped, so a wide-gamut color keeps its out-of-range channels.

To accept only some formats, compose the single-format parsers instead of calling parse(). Order them by how often each format shows up, and keep parseNameString last: every other parser rejects a foreign input on its first character or key, while the name lookup lowercases the string first.

import { parseHex, parseHsvObject, parseNameString, parseRgbObject } from '@colordx/core/fn';

const parsers = [parseHex, parseRgbObject, parseHsvObject, parseNameString];
const parseColor = (input: unknown) => {
  for (const p of parsers) {
    const rgb = p(input);
    if (rgb) return rgb;
  }
  return null;
};

Available: parse, parseHex, parseRgbString, parseRgbObject, parseSrgbColorString, parseHslString, parseHslObject, parseHsvString, parseHsvObject, parseHwbString, parseHwbObject, parseOklabString, parseOklabObject, parseOklchString, parseOklchObject, parseNameString, NAMES, rgbToHex, rgbToHex8, rgbToHsl / hslToRgb, rgbToHsv / hsvToRgb, rgbToHwb / hwbToRgb, rgbToOklab / oklabToRgb, rgbToOklch / oklchToRgb, rgbToOkhsl / okhslToRgb, rgbToOkhsv / okhsvToRgb, parseOkhslString, parseOkhslObject, parseOkhsvString, parseOkhsvObject.

Migrating from tinycolor2

@colordx/core/tinycolor is a drop-in replacement for tinycolor2. Swap the import (or the require) and keep the code:

import tinycolor from '@colordx/core/tinycolor'; // was: from 'tinycolor2'

tinycolor('#f00').lighten(20).toHexString();     // '#ff6666'
tinycolor('rgb 255 0 0').toHslString();          // 'hsl(0, 100%, 50%)'
tinycolor.mix('#f00', '#00f', 50).toHexString(); // '#800080'
tinycolor.isReadable('#777', '#fff');            // false
tinycolor('#f00').toColordx().toOklchString();   // 'oklch(0.62796 0.25768 29.23388)'

Same API, same input quirks (f00, rgb 255 0 0, { h: 0, s: 1, l: 0.5 } read as fractions), same output strings, mutable instances (lighten() changes the instance and returns it). Types come along: tinycolor.Instance, tinycolor.ColorInput.

What is different:

  • Precision. tinycolor2 truncates percentages to two decimals when it re-parses HSL between operations. colordx keeps full precision, so lighten(), darken(), saturate(), desaturate(), spin() and the harmony helpers can differ from tinycolor2 by 1/255 on a channel. The test suite checks every operation against tinycolor2 within that bound.
  • toName() returns 'rebeccapurple' for #663399; tinycolor2 returns false there (a lookup bug).
  • tinycolor.names holds full hex (ff0000); tinycolor2 holds shortened hex (f00).
  • CSS angle units on a hue (hsl(0.5turn 100% 50%), 200grad, 3.14rad) convert to degrees. tinycolor2 rejects them as invalid.
  • analogous() / monochromatic() always terminate. tinycolor2 loops forever on a negative or fractional count (#280); here it is floored, with anything below 1 treated as 1.
  • .toColordx() is new: the immutable Colordx underneath, for oklch, gamut mapping and plugins. tinycolor() reads a Colordx back, as its sRGB clip.

Performance

Apple M5 Pro, Node 24, using mitata. Operations per second — higher is better. Versions: colord 2.10.0, culori 4.0.2, chroma-js 3.2.0, color 5.0.3, tinycolor2 1.6.0, @texel/color 1.1.11.

Benchmark colordx @texel/color colord culori chroma-js color tinycolor2
Parse HEX → toHex 38M 8.0M 7.5M 7.1M 3.7M 1.6M 2.9M
Parse HEX → toHsl 35M — 29M 7.7M 3.8M 3.4M 2.8M
Parse RGB object → toHex 50M — 35M 49M 5.2M 1.6M 5.8M
Parse rgb() string → toHex 12M — 7.4M 3.1M 227K 1.5M 3.0M
Parse hsl() string → toHex 11M — 4.9M 3.1M 219K 1.4M 2.0M
Parse named color → toHex 14M — 4.5M 3.9M 4.5M 1.3M 2.6M
Parse HEX → lighten → toHex 18M — 13M 5.2M 1.9M 1.2M 1.1M
Mix two colors 19M 6.0M 2.5M 1.3M 1.3M 678K 1.3M
WCAG contrast ratio 21M — 3.6M 3.3M 2.0M — 1.5M
Parse HEX → toOklch 14M 6.6M — 4.7M 1.3M 2.6M —
inGamutP3 8.9M 3.9M — 1.5M — — —
inGamutRec2020 8.7M 3.9M — 1.5M — — —
CIEDE2000 delta 6.0M — — 2.0M 1.3M — —
OKLCH string → HEX 6.3M 2.8M — 1.7M 191K — —
Gamut map → sRGB 1.7M — — 509K — — —

In the RGB object row culori gets its own { mode, r, g, b } format, so it skips parsing.

Mean of two runs. Run pnpm bench to check.

Roadmap

CSS Color 4/5 completeness
  • color-mix() — parse and evaluate color-mix(in oklch, red 30%, blue) strings, with support for all interpolation spaces and polar hue methods (shorter, longer, increasing, decreasing)
  • Relative color syntax — oklch(from red l c h) and channel arithmetic like oklch(from red l calc(c + 0.1) h)

Ecosystem

  • @colordx/cli — color accessibility on the command line, and a skill for coding agents: check a pair, audit a token file, build a scale, solve a theme. WCAG 2.2 and APCA gates, one JSON record per pair, a fix on every fail.
  • @colordx/gpu — colordx's color math on the GPU: conversions and gamut tests as generated GLSL, parity-tested against this library. For per-pixel workloads — picker charts, gamut visualizations, gradients — where you convert millions of colors per frame instead of one at a time.

Sponsors

Supported by Ruslo.app.

License

MIT

Keywords