Byte by Mahmud logoByteby Mahmud
HSL and the 4.5:1 Ratio Are Both Lying to You. OKLCH and APCA Fix It.
Design & Creativity6 min read4 views

HSL and the 4.5:1 Ratio Are Both Lying to You. OKLCH and APCA Fix It.

Mahmud Hasan

Mahmud Hasan

October 4, 2026

Two conclusions kept landing after reading the CSS Color 4 spec, the APCA docs, and a pile of real-world migration commits: the color format you've been writing misleads your eyes by design, and the contrast checker your audits run is measuring the wrong thing. Both have replacements now, and both work in every modern browser.

HSL lies to you about lightness

Here's the one demo that ended the argument for me. These two colors share the exact same lightness value in HSL:

hsl(60 100% 50%) — a blinding yellow
hsl(240 100% 50%) — a deep, murky blue

Same L, wildly different brightness. HSL's lightness is a 1978 convenience that pretends every hue shares the same brightness curve. It doesn't — and HSL's cylinder fakes uniform saturation across hues that real displays and eyes don't share, which is where your muddy grays come from.

The damage isn't theoretical. Sass's darken() gave different jumps for blue and purple at the same 10% bump, and Evil Martians documented a sharper version: rotating a hue in HSL to make an "error red" from a brand accent changed the lightness too, silently dropping the button text below readable contrast. The same edit in OKLCH held contrast steady.

LCH tried to fix all this and introduced its own bug — an ugly hue shift in blues (hue 270–330) when you touched chroma or lightness. OKLCH exists because Björn Ottosson rebuilt the underlying space (Oklab, 2020) to kill that bug.

OKLCH, in plain terms

oklch(L C H / a) has three axes your brain can actually reason about:

  • L — perceived lightness, 0 (black) to 1 (white). A step of 0.05 looks like the same change at any hue.
  • C — chroma, 0 (gray) up to ~0.37 for the most vivid expressible colors.
  • H — hue angle, 0–360, same convention as HSL.

So this is a design token you can read without a picker:

.button { background: oklch(0.7 0.1 250); } /* blue */
.button:hover { background: oklch(0.75 0.1 250); } /* same blue, brighter */
.button.is-delete { background: oklch(0.7 0.1 20); } /* red, same weight */

Three lines, and the hover and delete states are derived, not eyeballed. That's the pitch: L means lightness for real, so you can do arithmetic on colors again.

The logistics are settled. CSS Color Module Level 4 became a Candidate Recommendation on July 5, 2022, and oklch() is supported in Chrome 111+, Safari 16.2+, and Firefox 113+ — safe since 2023, no polyfill. Tailwind v4 ships its default palette in OKLCH. There's a bonus HSL and hex can't touch: wide gamut. sRGB covers roughly 35% of the colors humans can see; P3 adds about 30% more, and every current Apple device plus most OLED screens renders it. oklch() addresses all of it in one syntax. One honest caveat: not every L/C/H combination exists on an sRGB screen. Browsers gamut-map to the closest visible color, but check saturated values in a picker like oklch.com before shipping them.

Palette math you can actually use

The real payoff is CSS Color 5's relative color syntax, supported by all current browsers (Chrome 119+, Firefox 128+, Safari 16.4+):

:root { --accent: oklch(0.7 0.14 113); }
.button:hover { background: oklch(from var(--accent) calc(l + 0.1) c h); }

One token in, every state out — hover, active, disabled, dark mode — computed from lightness math instead of eyeballed hex swatches. Hold chroma and hue, move lightness, and the color survives.

Watch out for the one trap that catches everyone: color-mix(in oklch, var(--primary) 80%, transparent) does not lighten your primary color — it fades it toward transparency, not toward white. A real project ADR put it bluntly: to brighten, adjust L; color-mix with transparent is for translucency, and confusing the two is a bug. It's a great tool for a different job: one team fixed dead Tailwind opacity modifiers (bg-warning/40 emitting no CSS at all) by deriving alpha with color-mix() in OKLCH instead of string-wrapping tokens in hsl(), which broke because their tokens were OKLCH, not HSL channels.

One more working rule from teams already on this: use oklab() (the rectangular sibling) for gradient interpolation to avoid RGB's gray dead-zone midpoints, and keep oklch() for authored tokens, because polar coordinates are the ones humans can read.

Now the harder truth: your contrast checker is wrong too

The WCAG 2 contrast ratio — (L1 + 0.05) / (L2 + 0.05), pass at 4.5:1 — overstates contrast for dark colors, and below roughly #a0a0a0 brightness the number stops being meaningful. Two examples from the APCA research make the point:

White text on a saturated orange: the old ratio says 2.97:1, fail — your audit flags it. But it's perfectly readable: APCA scores it Lc 61, a pass at the right sizes, and it's arguably more readable for people with color vision deficiency. The standard told you to "fix" a combination that was fine.

Now the reverse: #797979 gray text on near-black #110402. WCAG 2 says 4.5:1, pass — AA compliant, ship it. APCA says fail at Lc 31, and your eyes agree. The ratio passed a pairing nobody can comfortably read.

APCA — the Accessible Perceptual Contrast Algorithm, built by the W3C Silver Task Force as the contrast model for WCAG 3 — returns an Lc (lightness contrast) value where Lc 60 means the same perceived readability for any pair, light or dark. It's polarity-aware (dark-on-light scores positive, light-on-dark negative) and accounts for font size and weight. The working thresholds: Lc 75 body text for fluent reading; Lc 60 body minimum, large text, UI labels; Lc 45 large headings and non-text UI; Lc 15–30 placeholders, disabled states, borders; and a Lc 90 ceiling in dark mode, above which light text starts to halo.

Roughly: Lc 60 ≈ 3:1, Lc 75 ≈ 4.5:1, Lc 90 ≈ 7:1 in light mode. But in dark mode the old ratio has no useful guidance — WCAG 2's math cannot do dark mode, full stop — while APCA treats reverse polarity as its own case (body text wants about Lc 75 there). If you ship dark mode, APCA isn't optional; it's the only number that means anything.

The compliance catch

Here's the objection, and it's real: APCA is not the law. WCAG 2.2 AA — 4.5:1 for body text, 3:1 for large — is still what regulators, procurement contracts, and automated audit tools check. WCAG 3.0 is a draft with a Bronze/Silver/Gold model, and drafts change. So the honest posture is two-track: keep passing 4.5:1 for legal cover, and run APCA in parallel for actual readability. Tools like contrast-plus show both numbers side by side, and a VS Code extension (css-oklch) checks APCA right in your stylesheet. Where the two disagree, APCA is usually the one telling the truth about human eyes.

What to do this week

  • Define new tokens in oklch(). Keep the old hexes until you've remapped; the numbers are readable enough that the diff reviews itself.
  • Kill lighten/darken helpers. Replace with relative color (oklch(from var(--token) calc(l ± 0.08) c h)) so states derive from tokens.
  • Run every text pair through an APCA check. Fix the ones WCAG passed but APCA fails first — those are the real bugs your audits missed.
  • Never "lighten" with color-mix(..., transparent). Adjust L for brightness; use color-mix for actual translucency.
  • Pin floors in CI. One team runs a dependency-free script asserting |Lc| ≥ 60 on every text pair the app actually renders, reading the authored tokens — a token that regresses fails before it's ever built. Steal that.

OKLCH and APCA share one idea: stop trusting numbers that don't match your eyes. OKLCH makes L mean lightness; APCA makes "contrast" mean readable. Your next design-token audit has a checklist now.

References

Comments

Leave a comment

Your email stays private — only your name is shown.

More in Design & Creativity