Converts a CSS <color> between every colour space CSS Color 4 defines, measures the difference between two colours, and maps an out-of-gamut colour into a bounded RGB gamut with the spec's named algorithms. Nothing is randomly generated and nothing is silently clipped.
Implemented against CSS Color Module Level 4, W3C Candidate Recommendation Draft, 28 July 2026 — a Candidate Recommendation Draft, not a Recommendation; it republished twice in July 2026 alone. The exact snapshot is pinned by dated URL and SHA-256 (94a6c45541721f38…), and a committed script re-derives the five RGB→XYZ matrices from the spec's own chromaticity tables and the two Bradford matrices from the published cone-response matrix — in exact rational arithmetic — checks every forward/inverse pair against the identity, checks that the OKLab matrices really are the recalculated ones (they must map the D65 white to oklab(1 0 0)), and fails on drift.
The pipeline, step by step (§11)
- Parse a CSS
<color>, applying CSS's own parse-time clamping — the only clamping performed on an input, and always reported in meta.warnings.
- Linearize: undo the destination-independent transfer function, over the extended range so negative components reflect through the origin instead of being clamped.
- Linear RGB → CIE XYZ at the source space's white.
- Adapt with a linear Bradford transform only if the destination's white differs.
chromaticAdaptation is non-null exactly when this step ran.
- XYZ → the destination's rectangular form, then apply the destination's transfer function.
- Convert to the polar form if the destination is cylindrical — at which point a powerless component may become missing.
White points, explicitly
D65: srgb, srgb-linear, display-p3, display-p3-linear, a98-rgb, rec2020, xyz-d65, hsl, hwb, oklab, oklch.
D50: lab, lch, xyz-d50, prophoto-rgb.
prophoto-rgb is the only D50 RGB space, which inverts the usual rule: it needs no adaptation to reach lab() and it does need one to reach oklab() (§10.10/§10.11). Getting this backwards produces answers that look plausible and are wrong by about one Lab unit, so every response states its whitePoint and names the adaptation when one ran.
Nothing is silently clamped
/convert returns out-of-range components verbatim — color(rec2020 0.42053 0.97978 0.00579) in display-p3 really is color(display-p3 -0.350289 1.00707 -0.144209), negatives intact. hex is null outside the sRGB gamut rather than a clipped lie, inGamut reports all five bounded RGB gamuts, and every gamut verdict is computed on unrounded values with a 1e-7 per-component tolerance, so precision can never flip one. The only clamping anywhere in this endpoint is (a) CSS's own parse-time clamping of the input, always warned about, and (b) the final clip() step the CSS gamut-mapping algorithms themselves mandate on /gamut.
Powerless components become missing
A grey has no hue. #808080 reaches an OKLCh chroma of 3.3e-16 with atan2 returning a meaningless 180°, so — per §4.4.1 — the hue is reported as null in components, listed in powerlessComponents and serialized as none: oklch(0.59987 0 none). The thresholds are the spec's own tabulated ones, and this API follows the tabulated <=/>= forms (lch C ≤ 0.0015, oklch C ≤ 0.000004, hsl S ≤ 0.001, hwb W + B ≥ 99.999) rather than the normative prose's "less than", which differ only at a single measure-zero point. A hue you write yourself is never powerless — an identity conversion such as ?color=oklch(0.5 0 30)&to=oklch echoes the 30 back, exactly as §4.4.1 requires. That also means a css containing none cannot be fed straight back into this API: replace it with the explicit 0 that §4.4 says a missing component behaves as.
/gamut: what the CSS algorithms actually do
Both implemented CSS algorithms aim at constant-lightness, constant-hue chroma reduction in OKLCh — and both finish with a local clip, so the returned colour's OKLCh lightness and hue are not exactly the input's. Measured on this implementation over the 287 out-of-sRGB colours in a sweep of C = 0.3 × L 0.2→0.9 in 0.1 steps × hue 0→350 in 10° steps:
| method |
max L drift |
max hue drift |
max ΔEOK from the input |
binary-search-minde (§14.2.1) |
0.018 |
15.7° |
0.265 |
raytrace (§14.2.5) |
0.00026 |
2.9° |
0.266 |
clip (§14.1.1, not a CSS GMA) |
0.186 |
69.0° |
0.281 |
On that sweep the two CSS algorithms disagree by at most 0.073 ΔEOK measured as the gap between their ΔEOK-from-the-input, and by 0.076 ΔEOK measured between the two colours they return — two different quantities that both get called "disagreement", so both are quoted here. The spec lets a browser pick either algorithm, and they measurably differ.
That sweep is deliberately mild: it holds C at 0.3 and stops at L = 0.9. The extremes live at the two ends of the lightness range, so there is a second, wider sweep behind the numbers below — for each gamut, OKLCh L 0.01→0.995 in 0.005 steps × hue 0→357 in 3° steps × C 0.05→0.45 in 0.02 steps, keeping only the colours outside that gamut and discarding the lightness short-circuits: 316k–410k colours per gamut. Across its four physically realizable gamuts (srgb, display-p3, a98-rgb, rec2020) the worst cases are:
| method |
max L drift |
max hue drift |
max chroma increase |
max ΔEOK from the input |
max disagreement with the other CSS algorithm |
binary-search-minde |
0.0196 |
69.3° |
+0.0026 |
0.448 |
0.193 in cost, 0.195 between the two results |
raytrace |
0.00085 |
3.4° |
never raised it once |
0.448 |
(same pair) |
clip |
0.367 |
94.2° |
+0.0050 |
0.543 |
— |
So the honest statement about hue is not "the CSS algorithms preserve it". raytrace really does stay within about 3°, but at very low or very high lightness binary-search-minde rotates the hue about as far as clip does. What the ray trace buys instead is proximity to the true gamut boundary: on an sRGB sweep of L 0.05→0.95 in 0.025 steps × hue 0→350 in 10° steps × C 0.1→0.4 in 0.05 steps (7,935 out-of-gamut colours), against the exact constant-lightness, constant-hue boundary chroma located by 80-step bisection, raytrace lands within 0.038 of it, binary-search-minde within 0.105 and clip within 0.293.
clip is not claimed to be worse. On the same 287 colours naive clipping was closer in ΔEOK than binary-search-minde on 192 of them (67%). What makes it unusable is the hue: up to 69° of rotation against 15.7°. Clipping optimises nothing in particular; that is why CSS does not use it. Both numbers are reported and no ranking beyond them is claimed.
prophoto-rgb is the one gamut to treat carefully. Its primaries are, in the spec's own words, hyper-saturated and not physically realizable, so a constant-lightness, constant-hue ray can leave and re-enter its cube — "the chroma at which the colour enters the gamut" is not even single-valued. On the wide sweep the two CSS algorithms differ by up to 1.35 ΔEOK there (1.41 between the two colours returned) against 0.19 in the four realizable gamuts, because at very low lightness the §14.2.5 ray trace stops converging altogether rather than merely landing further out. ?color=oklch(0.01 0.07 195)&gamut=prophoto-rgb&method=raytrace returns color(prophoto-rgb 0.026813 0.008508 1) — a fully saturated blue of OKLCh chroma 1.411 for an input chroma of 0.07, 1.357 ΔEOK (≈68 JND) away, which is not a nearby colour at all — where binary-search-minde returns a near-black 0.0053 away. Both answers are inside the gamut and method says which one you asked for; on top of that, any mapping that raises the OKLCh chroma by more than 0.02 says so in meta.warnings, so a diverged result cannot arrive without a signal.
For the same reason chromaReduction is not guaranteed positive, from two separate causes. The mandatory final local clip nudges it slightly negative near white: the largest increase on the wide sweep is +0.0050, by clip into display-p3 at oklch(0.995 0.23 99), and +0.0026 by binary-search-minde at oklch(0.97 0.23 105), while raytrace did not raise the chroma once in a realizable gamut. Separately, in prophoto-rgb the ray trace can land 1.34 above where it started — that is the divergence above, and the case the warning fires on.
Difference metrics
/difference offers ciede2000 (CIE 142:2001, kL = kC = kH = 1 — the same implementation /api/color-vision uses, validated against all 34 published Sharma, Wu & Dalal vectors), cie76 (plain Euclidean distance in CIELAB) and oklab (ΔEOK, §20.2). The Lab-based metrics default to CSS's D50 white and accept whitePoint=d65; ΔEOK is defined only at D65 and rejects the parameter. CIE94 is deliberately not offered: it is asymmetric in reference/sample and comes in two parameter sets (graphic arts kL=1, textiles kL=2), so offering it without forcing that choice invites a wrong answer — and CIEDE2000 supersedes it.
The other colour endpoints, and what this one is not
/api/colors generates colours and harmony palettes in hex/rgb/hsl. It converts nothing between spaces.
/api/color-contrast answers one accessibility question: the WCAG 2.2 ratio for two opaque sRGB hex colours.
/api/color-vision simulates colour-vision deficiency. Its CIEDE2000 figures are computed in a D65-relative CIELAB — the white sRGB's own primaries define — while CSS's lab(), and therefore this endpoint, is D50. The same colour has different Lab coordinates on the two pages; neither is wrong, and this endpoint states its white point on every response.
- This endpoint is the measurement and conversion layer: no randomness, no accessibility verdict, no vision model.
Deliberately not implemented: CSS Color 5 (color-mix(), relative colour syntax, contrast-color(), light-dark(), device-cmyk()); none components on input — §4.4 makes a missing component behave as zero everywhere except when combining two colours, which this endpoint never does, so none on input could only ever mean 0 and is rejected in favour of writing that 0; currentcolor, transparent and system colours; calc() and every other math function; custom @color-profile spaces; the §14.2.3 EdgeSeeker gamut mapping, whose §14.2.4 pseudocode section is still an empty heading upstream; CSSOM legacy serialization (§16.2.2's rgb(255, 85, 0) form clamps to 0–255 and cannot express an out-of-gamut result, and §16.1.1's 8-bit alpha rule is a comma-form compatibility rule); ICC profiles, CMYK, display calibration, image processing and spectral data. Two documented quirks of the spec are also worth knowing: rec2020 here uses CSS's BT.1886 pure 2.4 power law, not the BT.2020 OETF with its 1.0993/0.0181 breakpoint, so an ITU-based library will disagree in the last decimals; and §10.7's inline ProPhoto linearization is misprinted (Math.pow(c, 1.8), which breaks negative components) — the §19 sample-code form is what runs here.