OKLCH Color Systems: One Base Color, Entire Token System
When you hand over a color system to your team, the worst-case scenario is this: your designers pick a brand red, the developers build a palette around it using HSL—and six months later, the mid-tone reds are unreadable over your backgrounds because the lightness formula broke in a way neither of you saw coming.
This article walks through why that happens, how OKLCH fixes it, and introduces CHROMA/1—a framework and tool I've built to generate entire design systems from a single base color, guaranteed perceptually uniform, with light/dark modes, WCAG validation, and components ready to copy.
The HSL Problem
HSL looks intuitive at first: hue (0–360°), saturation (0–100%), lightness (0–100%). You'd think that if you pick L=50 for two colors, they'd look equally bright, right?
Wrong.
hsl(0, 100%, 50%) /* red — actually looks bright */
hsl(240, 100%, 50%) /* blue — looks much darker */
This is the lightness perception gap. HSL measures lightness mathematically, but human eyes don't perceive it linearly. Yellow and blue at the same L value appear wildly different in actual brightness. This is fine for one-off colors, but the moment you try to scale a palette—say, generating a 50–950 scale for red and blue using the same L curve—the blue steps look wrong because the perceptual jumps are uneven.
Web designers have patched this with tweaks: nudge the saturation down, shift the lightness up, add special cases per hue. It works, but it's brittle and doesn't scale across brands.
OKLCH: Perceptual Linearity
OKLCH is an entirely different approach: it's based on OKLab, a color space engineered for perceptual uniformity.
oklch(L C H)
- L (lightness): 0–1, perceptually linear. A step from L=0.3 to L=0.4 looks the same brightness jump as 0.6 to 0.7, across any hue.
- C (chroma): how saturated the color is (roughly 0–0.4 for sRGB).
- H (hue): 0–360°.
Here's the magic: because L is perceptually uniform, a simple 11-step scale (50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950) works for every color. Red, blue, yellow—same L values mean same perceived brightness.
/* Red base #990000 */
--color-red-50: oklch(0.971 0.013 28);
--color-red-500: oklch(0.637 0.237 28);
--color-red-950: oklch(0.250 0.090 28);
/* Blue — same L curve, different hue */
--color-blue-50: oklch(0.971 0.013 259);
--color-blue-500: oklch(0.637 0.215 259);
--color-blue-950: oklch(0.250 0.070 259);
/* Both 50 look equally light. Both 500 look equally bright. */
One Base Color → Entire System
Here's the insight: if L and C curves are predictable and reusable, you only need one input—the brand color—and everything else derives mathematically.
The Method
Convert input hex to OKLCH:
#990000→oklch(0.429 0.176 29.23)Find anchor step: Look at the L value (0.429). That's perceptually between step 700–800, so place your brand color at step 800 (the anchor). Don't force it to 500.
Generate scale: Apply the standard L and chroma curves:
Step 500: L=0.637, C=1.0 × C_peak
Step 600: L=0.577, C=1.0 × C_peak
Step 700: L=0.505, C=0.87 × C_peak
Step 800: L=0.429, C=0.72 × C_peak (← your base sits here)
Derive accent (for CTAs): Rotate hue +180° (complementary), keep L/C structure. Isolated accent = Von Restorff effect = higher click-through.
Derive neutral: Same hue, ultra-low chroma (0.004–0.012) for gray that feels warm/cool depending on brand.
Build semantic tokens: error, warning, success, info get fixed hues (29°, 90°, 145°, 259°) but share the L/C boosts from the system—so they feel like family.
Dark mode: Not an inversion. Background L≈0.14 (not
#000), text L≈0.94 (not#fff), accent chroma down 15–30%. Elevate surfaces via lightness, not shadow.
All of that—with full WCAG 2.2 validation—from one hex input.
Practical: CHROMA/1 Studio
I built CHROMA/1 to make this repeatable and visual. It's a single HTML file (works offline, no build step).
- Input: one color picker.
-
Output:
- Interactive scale preview (click any swatch to copy OKLCH value).
- Token diffs: light vs. dark side-by-side, with contrast badges.
- WCAG audit: 6 checks (text, muted text, CTA, each mode).
- 6 real components (navbar, hero, pricing, form, stats, alerts) rendered live with tokens.
- Copy-ready code for each component.
- Export:
tokens.css(Tailwind v4),vars-only.css(vanilla),tokens.json(W3C DTCG).
Real-World Use
<!-- Download tokens.css from CHROMA/1 -->
<link rel="stylesheet" href="tokens.css">
<!-- Tailwind v4 picks up @theme automatically -->
<button class="bg-brand-600 text-white">Click me</button>
<!-- Or use CSS variables -->
<button style="background: var(--cta); color: var(--on-cta)">Click me</button>
Toggle light/dark mode on the page to stress-test both token sets in real time.
Why This Matters for Teams
- Consistency across brand: One system per brand. Swap the base color, everything else scales.
- No guesswork on contrast: OKLCH's perceptual linearity means a rule (L ≥ 0.4 gap = WCAG AA) actually holds.
- Dark mode doesn't break: Because you're not inverting—you're re-computing with the same formulas, just different L ranges.
- CRO built-in: Token architecture enforces 60-30-10 (neutral/structure/accent), preventing rainbow syndrome. CTA = only accent element on the page.
- Accessibility-first: WCAG checks run live; you see failures before shipping.
The Catch: Browser Support
OKLCH as a CSS keyword:
- Chrome 111+ ✅
- Safari 15.4+ ✅
- Firefox 113+ ✅
- Edge 111+ ✅
For older browsers, pre-compute hex values and export vars-only.css. The math doesn't change.
Numbers: What Changed Since HSL
Material Design 3 (Google) switched to HCT (a similar approach). Build times for their docs: no dramatic speedup, but consistency improved measurably.
Tailwind CSS v4 moved the entire default palette to OKLCH. From their blog:
"We've upgraded the entire default color palette from RGB to OKLCH, taking advantage of the wider gamut to make colors more vivid where we were previously limited by sRGB."
Teams at Shopify, Figma, Apple (via Adaptive Colors), and Stripe all use perceptually uniform spaces. The pattern is clear.
Try It Yourself
- Download
chroma1-oklch-studio.html(standalone, no build needed). - Enter your brand color.
- Export tokens.
- Drop
tokens.cssinto your next Tailwind project. - Swap the base color for a different brand—entire system re-derives, no manual tweaks.
For documentation and Astro 7 scaffold, see the full build at /docs (included in the studio).
Going Deeper
If you want to understand the math:
- OKLab: Björn Ottosson's breakdown of OKLab color space.
- W3C DTCG: Design Tokens Community Group's spec (reached Candidate Recommendation October 2025).
- WCAG 2.2 vs. APCA: WCAG is the legal standard (4.5:1 text); APCA is more accurate for dark mode but still draft.
One More Thing: AI Integration
The studio includes AI asistants (via OpenRouter, model nvidia/nemotron-3-super-120b-a12b:free free tier) that can:
- Suggest a brand color from a product description.
- Audit your palette for CRO + accessibility gaps.
- Name and narrate your palette for design docs.
No Claude API key needed—you bring your own OpenRouter key (free tier available).
CHROMA/1 is open and MIT-licensed. The tool is a single HTML file with zero dependencies (not even Tailwind); the math is vanilla JavaScript. Astro 7 starter kit included.
If you build a color system using OKLCH, or improve on this framework, I'd love to hear about it—drop a comment or reach out.
One color. Entire system. Perceptually sound. No regrets.
Top comments (0)