OKLCH
Colour named the way your eyes measure it: how light, how colourful, which hue.
Ask HSL for a blue and a yellow at fifty percent lightness and you get dusk next to a glare; our eyes never agreed to that. OKLCH names colour with the three numbers your eyes actually measure, and this page is the story of pulling on them: palettes that hold their balance, and the places where a screen simply runs out of colour.
The lightness lie
I started this page because of a row of tags in a side project, every one a different hue from the same HSL recipe, and together they read like a ransom note: the yellow shouted, the blue sank, every fix for one tag broke another. The numbers insisted the tags matched. My eyes kept filing the same bug.
So the first build is the simplest possible test: every hue in a line, pinned to the same stated lightness, HSL on the top row, OKLCH on the bottom. Drag the slider and the top row flickers between glare and dusk while the bottom rises and falls as one. Then flip the switch, which repaints every swatch as the grey your eye reads it as: the HSL greys scatter, the OKLCH greys match to the digit.
Hover any swatch for its exact value. When a hue cannot hold chroma 0.11 at this lightness inside sRGB, that swatch quietly lowers its chroma and the readout says so. Lightness is never touched.
One catch in that bottom row is worth being honest about: not every lightness, chroma, and hue exists on a screen, so near the ends of the slider this demo lowers the chroma of the affected swatches and says so in the readout instead of letting the browser guess. That edge deserved a demo of its own.
Where the screen runs out
So the next build maps that edge, one hue at a time: lightness up the side, chroma across, colour painted only where sRGB can actually go; the empty space is absence, not styling. Sweep the hue and the silhouette lurches, yellows carrying their chroma near white, blues keeping theirs in the dark, nothing about the shape saying cylinder.
Drag the dot anywhere, including off the edge. Ask for a colour that does not exist and the map keeps your lightness and hue, walks the chroma back to the edge, and shows both points: the ring you asked for and the dot you got. The hatched fringe past the edge is Display P3, the wider gamut most new screens ship, drawn here as its nearest sRGB stand-in, the same trade your CSS makes.
Drag the dot anywhere on the map, or use the sliders. Past the white line sRGB has no pixel for what you asked, so the map keeps your lightness and hue and walks the chroma back to the edge: the hollow ring is the request, the dot is what a screen can show. The hatched fringe belongs to Display P3, painted here with its nearest sRGB stand-in.
Once you see colours as points in a lumpy solid, a gradient stops being abstract: it is a line between two of those points, and the space you draw the line through decides what the middle looks like. The old spaces route some of those lines straight through grey mud.
The line through the mud
Plain CSS mixes in sRGB, averaging the red, green, and blue numbers, and for hues on opposite sides of the wheel those averages meet near grey, the dead zone in so many blue to yellow gradients. Nobody asked for grey; the arithmetic just passes through it.
So this build looks straight down the lightness axis, the solid seen from above: hue around the rim, grey at the dead centre, each endpoint the most colourful sRGB pixel its hue owns. The sRGB mix cuts straight across the middle and greys out on the way; the OKLCH mix walks around the wheel and keeps its colour. Drag either endpoint and the strips lay both routes flat, the way you would actually ship them.
Each endpoint is its hue's most colourful sRGB pixel, the sharp tip of the last map's silhouette. Drag an endpoint around the wheel, or use the sliders. Mixing in sRGB averages the stored channel numbers, so distant hues meet near the grey centre. Mixing in OKLCH walks the shorter way around the wheel and holds its chroma; where it asks for more than sRGB carries, the dashed line is the ask and the solid line is what a screen shows.
The honesty rule still applies: a line between two sharp tips of a lumpy solid leaves it almost immediately, so at full vividness the OKLCH route rides the gamut edge and the readout counts every step walked back. It still never falls anywhere near the grey pit the sRGB mix sinks into, and by then I trusted the three numbers enough to want them on real work.
Move one number
Real products are never one colour. They are a background, a card edge, two strengths of text, a button and its hover, and all of it again after dark; picked by eye, those pieces drift. The three numbers offer a quieter deal: fix the chroma and the hue, step the lightness down a ladder, and every rung is the same colour doing a different job.
So the last build is a theme machine: one hue and one chroma feed a colourful accent ladder and a near-grey surface ladder that keeps a whisper of the hue, and the two little screens below are painted with nothing else. Drag the hue and a whole product changes its mind without losing its manners: the lightness rungs never move, so the text keeps its contrast, and dark mode is the same ladder read from the other end. Then flip the switch, which restates the very same numbers in plain HSL, the way a lighten() and darken() ladder is built, and the promise comes apart: the mid rungs blow out toward white, dark mode goes pale, and the readout counts the damage. Drag the hue with the switch on and the damage moves with it, mildest in the blues, worst in the cyans and yellows; flip back and the same hue holds every rung.
The two screens are painted only from the ladders under them: surfaces and text from the near-grey ramp, the badge and buttons from the colourful one. Hover a Send invoice button; hover is one rung up the ladder, so every hue brightens by the same perceived amount. Flip the switch and the same rung numbers are restated in plain HSL, hue and saturation held from the 500 rung: rungs land off their stated lightness (the readout counts the worst), the whole theme washes lighter, and the drift changes as you drag the hue. In OKLCH, where a rung asks past the sRGB edge it gives up chroma only, never lightness, and the readout says so. Hover any rung for its exact value.
The gamut gets the last word here too. Swing the hue toward yellow or cyan and the dark rungs ask for more chroma than sRGB can hold, so the ladder gives up colourfulness, never lightness, and the readout counts the cost. That trade is the whole page in one sentence: say what you want in the numbers your eyes use, and be honest about what the screen can do.
Next is the hatched fringe from the second map: Display P3 screens can already show colours these ladders politely decline, and the same three numbers name them without breaking step.
Code
The real source that powers the demos, ready to copy.
/** * Minimal OKLCH and sRGB math for the demos, following Bjorn Ottosson's * reference Oklab implementation (https://bottosson.github.io/posts/oklab/). * * The demos convert colours themselves instead of handing oklch() strings to * the browser so that out-of-gamut handling stays honest and visible: chroma * is clamped toward grey while lightness and hue are preserved, and the UI * can say exactly when that happened. *//** Gamma-encoded sRGB with channels in [0, 1] when in gamut. */export type Rgb = { r: number; g: number; b: number };/** sRGB transfer function, linear to gamma (IEC 61966-2-1). */function linearToGamma(x: number): number { return x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055;}/** sRGB transfer function, gamma to linear. */function gammaToLinear(x: number): number { return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;}/** * OKLCH to linear sRGB, unclamped: channels stray outside [0, 1] when the * requested colour does not exist in sRGB. Hue is in degrees. */export function oklchToLinearRgb(l: number, c: number, h: number): Rgb { const hr = (h * Math.PI) / 180; const a = c * Math.cos(hr); const b = c * Math.sin(hr); const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3; const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3; const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3; return { r: 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_, g: -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_, b: -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_, };}/** OKLCH to gamma sRGB, unclamped. Hue is in degrees. */export function oklchToRgb(l: number, c: number, h: number): Rgb { const lin = oklchToLinearRgb(l, c, h); return { r: linearToGamma(lin.r), g: linearToGamma(lin.g), b: linearToGamma(lin.b), };}/** Whether an sRGB triple is displayable, with a small numeric tolerance. */export function inSrgbGamut(rgb: Rgb, eps = 1e-4): boolean { return ( rgb.r >= -eps && rgb.r <= 1 + eps && rgb.g >= -eps && rgb.g <= 1 + eps && rgb.b >= -eps && rgb.b <= 1 + eps );}/** * Whether an OKLCH colour fits inside Display P3, checked in linear space. * Linear sRGB to linear P3 is a single matrix (both are D65), and the * transform stays valid for out-of-range sRGB channels because it is linear. */export function inP3Gamut( l: number, c: number, h: number, eps = 1e-4,): boolean { const { r, g, b } = oklchToLinearRgb(l, c, h); const pr = 0.8224621129 * r + 0.1775378871 * g; const pg = 0.0331941988 * r + 0.9668058012 * g; const pb = 0.0170826319 * r + 0.0723974406 * g + 0.9105199276 * b; return ( pr >= -eps && pr <= 1 + eps && pg >= -eps && pg <= 1 + eps && pb >= -eps && pb <= 1 + eps );}/** Largest chroma at (l, h) that still satisfies a gamut test, by bisection. */function maxChromaFor( l: number, h: number, fits: (l: number, c: number, h: number) => boolean,): number { const ceiling = 0.5; if (!fits(l, 0, h)) return 0; if (fits(l, ceiling, h)) return ceiling; let lo = 0; let hi = ceiling; for (let i = 0; i < 24; i++) { const mid = (lo + hi) / 2; if (fits(l, mid, h)) lo = mid; else hi = mid; } return lo;}/** The sRGB gamut edge at (l, h): the largest displayable chroma. */export function maxSrgbChroma(l: number, h: number): number { return maxChromaFor(l, h, (ll, cc, hh) => inSrgbGamut(oklchToRgb(ll, cc, hh)), );}/** The Display P3 gamut edge at (l, h). */export function maxP3Chroma(l: number, h: number): number { return maxChromaFor(l, h, (ll, cc, hh) => inP3Gamut(ll, cc, hh));}/** * Chroma clamped to what sRGB can display at (l, h). Preserving L and H and * giving up only chroma is the gamut mapping the demos advertise. */export function clampChromaToSrgb( l: number, c: number, h: number,): { c: number; clamped: boolean } { if (inSrgbGamut(oklchToRgb(l, c, h))) return { c, clamped: false }; return { c: Math.min(c, maxSrgbChroma(l, h)), clamped: true };}/** Oklab coordinates; a and b are the rectangular chroma axes. */export type Oklab = { l: number; a: number; b: number };/** Gamma sRGB to Oklab, the inverse direction of oklchToRgb. */export function rgbToOklab(rgb: Rgb): Oklab { const r = gammaToLinear(rgb.r); const g = gammaToLinear(rgb.g); const b = gammaToLinear(rgb.b); const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); return { l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, };}/** Perceptual (Oklab) lightness of a gamma sRGB colour. */export function rgbLightness(rgb: Rgb): number { return rgbToOklab(rgb).l;}/** * The sRGB cusp at a hue: the lightness where the gamut reaches its largest * chroma, in other words the most colourful displayable colour of that hue. */export function srgbCusp(h: number): { l: number; c: number } { let bestL = 0.5; let bestC = 0; for (let i = 1; i < 64; i++) { const l = i / 64; const c = maxSrgbChroma(l, h); if (c > bestC) { bestC = c; bestL = l; } } for (let step = 1 / 128; step > 1 / 2048; step /= 2) { for (const l of [bestL - step, bestL + step]) { const c = maxSrgbChroma(l, h); if (c > bestC) { bestC = c; bestL = l; } } } return { l: bestL, c: bestC };}/** HSL to gamma sRGB (CSS definition). Hue in degrees, s and l in [0, 1]. */export function hslToRgb(h: number, s: number, l: number): Rgb { const hue = ((h % 360) + 360) % 360; const c = (1 - Math.abs(2 * l - 1)) * s; const hp = hue / 60; const x = c * (1 - Math.abs((hp % 2) - 1)); let rgb: [number, number, number]; if (hp < 1) rgb = [c, x, 0]; else if (hp < 2) rgb = [x, c, 0]; else if (hp < 3) rgb = [0, c, x]; else if (hp < 4) rgb = [0, x, c]; else if (hp < 5) rgb = [x, 0, c]; else rgb = [c, 0, x]; const m = l - c / 2; return { r: rgb[0] + m, g: rgb[1] + m, b: rgb[2] + m };}/** Gamma sRGB to HSL (CSS definition). Hue in degrees, s and l in [0, 1]. */export function rgbToHsl(rgb: Rgb): { h: number; s: number; l: number } { const r = Math.min(1, Math.max(0, rgb.r)); const g = Math.min(1, Math.max(0, rgb.g)); const b = Math.min(1, Math.max(0, rgb.b)); const max = Math.max(r, g, b); const min = Math.min(r, g, b); const l = (max + min) / 2; const d = max - min; if (d < 1e-9) return { h: 0, s: 0, l }; const s = d / (1 - Math.abs(2 * l - 1)); let h: number; if (max === r) h = ((g - b) / d) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; return { h: (((h * 60) % 360) + 360) % 360, s, l };}/** Hex string for an sRGB colour, clipping channels to [0, 1]. */export function rgbToHex(rgb: Rgb): string { const channel = (x: number) => Math.round(Math.min(1, Math.max(0, x)) * 255) .toString(16) .padStart(2, "0"); return `#${channel(rgb.r)}${channel(rgb.g)}${channel(rgb.b)}`;}/** The grey with a given perceptual lightness, as hex. */export function greyHex(l: number): string { return rgbToHex(oklchToRgb(l, 0, 0));}Credits
Where this came from.
- Sources
- Björn Ottosson, A perceptual color space for image processingEvil Martians, OKLCH in CSS: why we moved from RGB and HSLEvil Martians, OKLCH colour picker and converterAdam Argyle, High Definition CSS Color GuideSmashing Magazine, Falling For Oklch
- Date
- July 2026
- Tags
- OKLCHColourCSSPerception
- Author
- Zhiyuan Guo
Zhiyuan Guo
zhiyuanguo/website