When I started using oklch in my projects I thought the contrast problem was solved: oklch has a perceptual L (lightness) component, so two colors with the same L look equally bright. Spoiler: it's not that simple. WCAG AA uses a contrast metric based on sRGB, not perceived lightness. This is the methodology I use now.
Why oklch in the first place
HSL is treacherous. Two colors with the same L can look very different to the eye: hsl(60 100% 50%) (yellow) looks much brighter than hsl(240 100% 50%) (blue). It's because HSL doesn't respect how the human eye perceives brightness.
Oklch does. When you say oklch(76% 0.230 132) and oklch(76% 0.230 240), both look equally "bright" perceptually. That lets me build a palette where the green accent, violet secondary, and grays have coherent "weights" across hues.
But — and it's a big BUT — WCAG contrast isn't measured in oklch. It's measured in sRGB with WCAG 2.1's relative luminance formula. That means you can have two colors with the same perceptual L and completely different WCAG contrast.
The concrete problem I faced
In VantLabs' palette, the accent is oklch(76% 0.230 132) — a bright lime. My instinct was that any white text (#ffffff) on that lime should be readable: a "light" background with "lighter" text should contrast like any Tailwind bg-blue-500 button.
But lime at 76% L has very high sRGB luminance. White text on that lime gives ~1.8:1 contrast — way below the 4.5:1 WCAG AA requires for normal text. The text disappears. The solution is to use dark text (almost black) on the lime — contrast ~14:1, perfect.
The lesson I learned: high-chroma colors with L 70-85% are traps. They look bright but their sRGB luminance isn't what your intuition expects. White text bounces off them; dark text grounds them.
My verification flow
Three steps, all manual but fast:
- Generate the palette in oklch. Pick the hues, define the 50→950 scale with progressive L (97% → 8%) and max chroma in the middle (500). Lives in
tokens.css. - Convert each critical pair to sRGB and check contrast. I use oklch.com to convert and WebAIM Contrast Checker to verify. Critical pairs: text-on-bg-base, text-on-bg-surface, text-on-accent-fill, secondary-text-on-bg-base.
- Validate dark mode separately. Same tests but with dark theme semantic tokens. WCAG is mode-independent, but the pairs change completely on inversion.
In total, every time I tweak the palette, the verification cycle takes me ~15 minutes. Manual but reliable.
What I automate
I have a small script that takes tokens.css, parses the oklch CSS variables, converts them to sRGB using culori (a JS color science library), and computes WCAG contrast between critical pairs. If any falls below 4.5:1 (normal text) or 3:1 (large text), the script fails with exit code 1. I run it as part of pre-commit checks.
It's 80 lines of code and has saved me from shipping two problematic palettes. When I clean up the code someday, I'll open it on GitHub as a tiny utility.
Focus rings, the most-forgotten detail
Most devs check text contrast and forget focus rings. WCAG 2.1 requires the focus indicator to have at least 3:1 contrast with the adjacent background (not with the component, but with the space behind it). That means a lime green focus ring on a white background probably does NOT pass.
The solution in VantLabs: the focus ring is oklch(56% 0.190 130) (volt-700), not accent-500. It's darker, keeps the brand color's identity, and passes contrast over any background on the site. Visually it's still "the brand green", just the version that works for focus.
What I'm still learning
APCA (Accessible Perceptual Contrast Algorithm) is WCAG 3.0's proposed replacement for the current contrast calculation. It's mathematically more correct, especially for high-chroma colors like mine. It's not standard yet, but tooling exists and it'll be the future. My plan: when WCAG 3 is stable, migrate my script to APCA and re-verify everything. For now, AA in sRGB is the floor.