Web Developmenteditorial12 min read

Your HSL Palette Lies to You

Equal HSL lightness is not equal brightness. OKLCH fixes the math your design system depends on.

You ran darken($blue, 10%) and darken($yellow, 10%) through the same Sass mixin. Same function, same percentage, same design system. The blue badge looked slightly subdued. The yellow badge looked like someone left it in the oven. Your QA spreadsheet said both colors had lightness fifty percent in HSL. Your eyes disagreed.

That gap between the number on the token and what users actually see is not a tooling bug. It is a coordinate-system problem. HSL was built for color pickers and tutorial diagrams, not for perceptually uniform design ramps. OKLCH, derived from Björn Ottosson’s Oklab color space, was built so that equal steps in lightness correspond more closely to equal steps in perceived brightness, regardless of hue. If your palette, contrast checks, and semantic color tokens still live in HSL, you are calibrating against a map that stretches and compresses terrain depending on which direction you walk.

Adam Argyle, who helped drive CSS Color Module Level 4 and the OKLCH syntax into browsers, has described HSL as fine for one-off tweaks and wrong for systems. Evil Martians’ migration guide remains the most practical long-form reference for teams moving production palettes. MDN’s OKLCH documentation is the canonical syntax reference once you start writing tokens.

This article walks through why HSL fails in production, dissects the yellow-and-blue lightness trap, compares nine-step ramps side by side, and lays out a migration path you can execute without rewriting your entire product in a weekend.

Why HSL feels intuitive and fails in production

HSL stands for hue, saturation, lightness. You pick an angle on a color wheel, push saturation outward, and slide lightness up or down. In a color-picker UI, that model is easy to explain. In a design system with twelve hues and five steps each, it becomes a liability.

The core issue is that HSL lightness is not perceptual lightness. It is a geometric property of the RGB cube, remapped into a cylinder. Yellow, green, and cyan occupy enormous visual territory at mid-range lightness values. Blue and red need very different RGB mixtures to hit the same HSL lightness number, and they still do not look equally bright to a human observer. Ottosson documented this when he introduced Oklab in 2020. His goal was a space where Euclidean distance approximates perceived color difference, a property designers assume when they say make this ten percent darker. Oklab achieved that through an opponent-color transform and a cube-root nonlinearity tuned against human perception data. OKLCH is the cylindrical form of Oklab: L for lightness on a zero-to-one or zero-to-one-hundred-percent scale, C for chroma, H for hue angle. The letters resemble HSL, but the math underneath is entirely different.

Semantic colors expose the lie fastest. Warning yellow and danger red need different HSL lightness values to feel equally urgent on white backgrounds. In OKLCH you can hold lightness and chroma steadier while rotating hue. Accessibility checks compound the problem: WCAG contrast is computed from relative luminance, not HSL lightness. A palette that looks evenly spaced in HSL produces uneven contrast ratios across hues at the same nominal step. Your spreadsheet says step five hundred for every hue. Your users see a chorus where one voice is shouting.

The yellow and blue trap in production numbers

Set three hues to HSL lightness fifty percent and compare what you get against OKLCH at a matched perceptual mid-light value. The HSL version puts yellow at a coordinate that dominates any layout you put it in. Blue at the same nominal lightness reads as a reasonable primary brand color. Green sits somewhere between, but not halfway in any perceptual sense. The OKLCH version carries similar visual weight across all three swatches because the lightness channel was fit to approximate perceived brightness rather than RGB geometry.

This is not an academic exercise. It breaks darken and lighten utilities. hsl(from var(--brand) h s calc(l - 10%)) does not darken all brand hues by the same perceived amount. OKLCH’s calc(l - 0.1) aligns more closely with what users see. It breaks chart palettes where step five hundred should mean the same emphasis for every series. It breaks dark mode when you invert HSL values and warning turns olive while danger neonates because hue and lightness were never orthogonal in the source space.

/* HSL — same L, wildly different perceived brightness */
.swatches-hsl {
  --yellow: hsl(54 100% 50%);
  --green:  hsl(142 76% 36%);
  --blue:   hsl(220 90% 56%);
}

/* OKLCH — matched L, matched perceived weight */
.swatches-oklch {
  --yellow: oklch(0.65 0.18 95);
  --green:  oklch(0.65 0.17 145);
  --blue:   oklch(0.65 0.17 250);
}

Ottosson’s hue sweep plots remain the most persuasive artifact for teams still skeptical of notation changes. He plots HSV at constant value and saturation, then plots Oklab-predicted lightness of that same sweep. The curve spikes at yellow and cyan, proving the non-uniformity is measurable, not subjective taste. When your token sheet says lightness fifty percent, ask fifty percent of what. HSL’s answer and OKLCH’s answer diverge exactly where dashboards look uneven.

When conversion finished but the ramps still lied

A logistics SaaS company completed an approved OKLCH migration in a single quarter. Every primitive converted from hex and HSL through CSS Color 4 algorithms. Visual regression snapshots matched before and after. Leadership closed the epic. Six weeks later, customer success reported that the new brand yellow looked aggressive on invoice status badges while cobalt status chips looked timid, despite both using step five hundred from the official ramp.

Investigation started in Figma, where the ramps looked even. Investigation ended in the token file, where step five hundred was honest OKLCH notation but steps one hundred through nine hundred were still HSL lightness increments left over from a generator script nobody had rerun. The conversion project had transliterated anchor values without rebuilding the scale geometry. Blue’s nine hundred step was a dark navy that passed contrast. Yellow’s nine hundred step was a brown that designers had manually patched in Figma but not in CSS because the patch lived as a component override, not a token correction.

The design-ops lead reopened the work as a ramp rebuild, not a format swap. For each hue family, they fixed step five hundred as the signed anchor, then regenerated steps with uniform OKLCH lightness decrements of roughly zero point one, tapering chroma at the extremes where high saturation at very light or very dark coordinates produces fluorescent tints that do not exist on real displays. Yellow required more chroma taper than blue. That was expected. OKLCH makes the taper visible in numbers instead of hiding it in forty manual exceptions.

Semantic status colors were rebuilt as a trio sharing intentional lightness relationships. Success and danger shared lightness zero point five two on light backgrounds because they play the same UI role. Warning sat higher at zero point seven five because yellow-family hues need headroom to remain legible without turning brown. Dark mode swapped lightness values while preserving hue and chroma relationships so warning still read as caution after the theme flip.

Contrast failures surfaced that HSL’s lies had hidden. Muted text on raised cards failed when OKLCH honesty made the lightness delta visible. Fixing tokens beat patching components because the compliance team wanted evidence at the source. The second phase took longer than the conversion spreadsheet predicted and shorter than a full visual redesign would have taken. The internal postmortem’s lesson was blunt: converting hex to OKLCH notation without rebuilding ramps is an empty victory. Notation without geometry is still a lie, just in a newer syntax.

Side-by-side ramps and semantic palettes

Design systems often define nine-step ramps. An HSL blue ramp stepping lightness from ninety-five percent down to fifteen percent while holding hue and saturation constant collapses at the ends. Steps one hundred through three hundred become near-indistinguishable pastels. Steps seven hundred through nine hundred bunch in muddy territory. The middle third feels usable. The ends lie. Plot those steps in a contrast checker against white and the curve confirms what your eyes already knew.

An OKLCH blue ramp with hue two fifty, chroma tapering at extremes, and lightness stepped in zero point one increments produces a middle where each decrement feels similar. Your five hundred to six hundred hover state looks as intentional as your two hundred to three hundred border shift. Repeat for yellow and the HSL version falls apart faster. Designers manually crush saturation at each step, a patchwork of exceptions that OKLCH absorbs into the chroma parameter.

:root {
  /* HSL blue ramp — even numbers, uneven perception */
  --blue-hsl-500: hsl(250 80% 55%);

  /* OKLCH blue ramp — perceptually even steps */
  --blue-oklch-100: oklch(0.95 0.04 250);
  --blue-oklch-300: oklch(0.82 0.12 250);
  --blue-oklch-500: oklch(0.62 0.17 250);
  --blue-oklch-700: oklch(0.42 0.15 250);
  --blue-oklch-900: oklch(0.22 0.08 250);
}

Once ramps are stable, semantic tokens become tractable. Success and danger can share lightness because they share role. Warning can sit higher with explicit reasoning in the numbers rather than trial and error in HSL triplets.

:root {
  --success: oklch(0.52 0.14 145);
  --warning: oklch(0.75 0.14 85);
  --danger:  oklch(0.52 0.20 25);
}

[data-theme="dark"] {
  --success: oklch(0.72 0.14 145);
  --warning: oklch(0.82 0.14 85);
  --danger:  oklch(0.72 0.18 25);
}

Gradients between brand hues inherit sane math when endpoints share a coordinate system. Interpolation in OKLCH avoids the gray dead zone that sRGB and HSL paths produce when blue and coral meet in the middle. That topic connects directly to gradient interpolation space, but the palette implication is simple: if brand blue and brand coral were tuned as OKLCH endpoints, the gradient between them inherits sane math for free.

Migration without a big bang

Moving a live design system from HSL to OKLCH is a coordinate conversion followed by a geometry rebuild, not a one-step rename. Evil Martians recommend treating the first pass as a token-format swap while preserving visual output users already approved, then tuning ramps once the math is honest.

Export your existing palette from Figma variables, Style Dictionary JSON, or Sass maps. Convert every value to OKLCH using a tool that implements the CSS Color 4 conversion algorithms. Browser DevTools show OKLCH equivalents when you inspect computed colors. Do not round aggressively. Store at least three decimal places for L and C. Search for preprocessor functions: darken, lighten, saturate, mix, adjust-hue. Each encodes HSL or sRGB assumptions. Replace with OKLCH relative syntax or explicit step tokens.

After conversion, your one hundred through nine hundred scales will still carry HSL’s uneven spacing until you re-derive them with uniform OKLCH lightness increments and chroma taper at the ends. Check contrast against both default surface and raised surface if you support dark mode. Ship sRGB fallbacks during rollout for browsers that do not parse OKLCH:

:root {
  --brand-500: #3b82f6;
  --brand-500: oklch(0.588 0.154 250);
}

Browsers that understand OKLCH override the hex. Older browsers keep working. Validate on real components, not only swatch grids. OKLCH surfaces pairs that looked fine in HSL but failed luminance math. Fix tokens, not individual components, so the cascade carries the correction everywhere.

Chroma taper at ramp extremes is a feature of honest color systems, not a compromise. Very light tints cannot sustain high chroma in the physical world or on most displays. HSL pretends eighty percent saturation at ninety-five percent lightness is valid, producing fluorescent pastels that look nothing like print or photography. OKLCH makes overshoot visible in the numbers so designers taper chroma deliberately rather than discovering failure on a tinted panel during QA. Yellow families need more aggressive taper than blue families. That is expected. Document the taper rationale per hue in your token README so the next engineer does not flatten chroma back to a single constant.

Interpolation and mixing inherit palette geometry. A hero gradient between two OKLCH brand stops avoids the gray dead zone that sRGB paths produce. color-mix(in oklch, …) for tinted surfaces inherits the same perceptual path. Teams that fix notation without fixing ramps and mix space still ship uneven UI because three separate layers, storage format, step spacing, and interpolation space, must agree.

Training designers and engineers on the same vocabulary prevents relapse. When both sides say step five hundred, they should mean the same anchor in OKLCH with documented hue and chroma, not whichever HSL triplet Figma’s picker displayed last. Internal lunch-and-learn slides that show Ottosson’s HSV sweep plot next to your brand yellow badge cost one hour and prevent quarters of hue-by-hue exceptions.

OKLCH is not an accessibility pass. You still need WCAG contrast checks, color-blind simulation, and real-user testing. It is not infinite gamut. Colors outside sRGB may clip on older displays. What it fixes is the daily lie your token sheet tells: that lightness fifty percent means the same thing for yellow and blue, that a uniform darken percentage produces uniform perceived darkening, that a nine-step ramp is nine perceptually even jumps. Oklab’s transform, expressed as OKLCH in CSS, gives you a coordinate system aligned with human vision, exactly what a design system palette needs. Stop tuning exceptions hue by hue. Convert the coordinates, rebuild the ramps, and let the math carry your intent.