Skip to content

OKLCH Color Space: Why You Should Stop Using HSL

I spent an entire afternoon trying to build a consistent color palette using HSL. I had my brand color picked out, calculated the tints and shades using simple math, and felt pretty good about my “systematic” approach.

Then I tried to use it in a real interface.

My yellow tints looked washed out. My blue tints looked fine. My red darkened into an ugly brownish tone instead of a rich burgundy. And don’t even get me started on contrast—my “50% lightness” text was perfectly readable on some colors but invisible on others.

The math was correct. The colors were wrong.

The Problem: HSL Is Perceptually Broken

Here’s the core issue: HSL is mathematically consistent but perceptually inconsistent.

Look at these two colors:

hsl-comparison.css
/* Both at 50% lightness in HSL */
--yellow: hsl(60, 100%, 50%); /* Looks BRIGHT */
--blue: hsl(240, 100%, 50%); /* Looks DARK */

Same lightness value. Completely different perceived brightness to the human eye.

This isn’t a small quirk—it’s a fundamental flaw that makes HSL unsuitable for building color systems. When you create a palette by uniformly adjusting lightness, you get wildly inconsistent results across different hues.

I tried to compensate. I manually tweaked each color. I created exceptions for “problem hues.” I ended up with a mess of magic numbers that I couldn’t explain to anyone else on my team.

Enter OKLCH: Perceptually Uniform Colors

OKLCH (pronounced “ok-lch”) was designed to match human perception. It stands for:

  • Oklab (the underlying color space)
  • Lightness (0-100%, perceptually uniform)
  • Chroma (saturation, 0-0.4+)
  • Hue (0-360 degrees)

The key difference? Equal steps in lightness actually look equal across all hues.

oklch-comparison.css
/* Both at 50% lightness in OKLCH */
--yellow: oklch(70% 0.15 90); /* Adjusted - appears same brightness */
--blue: oklch(50% 0.15 240); /* Adjusted - appears same brightness */

Notice how I had to use different lightness values (70% vs 50%) to achieve similar perceived brightness? That’s the power of perceptual uniformity—you’re working with how colors actually appear, not mathematical abstractions.

Building a Real Color System

Here’s how I structure a complete palette now:

color-palette.css
:root {
/* Primary brand color */
--color-primary: oklch(60% 0.15 250);
--color-primary-light: oklch(75% 0.10 250);
--color-primary-dark: oklch(40% 0.12 250);
/* Tinted neutrals - NOT pure gray */
--gray-50: oklch(98% 0.005 250);
--gray-100: oklch(95% 0.008 250);
--gray-200: oklch(90% 0.010 250);
--gray-300: oklch(82% 0.012 250);
--gray-400: oklch(70% 0.010 250);
--gray-500: oklch(55% 0.010 250);
--gray-600: oklch(45% 0.010 250);
--gray-700: oklch(35% 0.012 250);
--gray-800: oklch(25% 0.010 250);
--gray-900: oklch(15% 0.008 250);
/* Accent colors */
--accent-success: oklch(65% 0.18 145); /* Green */
--accent-warning: oklch(75% 0.15 85); /* Yellow */
--accent-danger: oklch(55% 0.20 25); /* Red */
}

The Tinted Neutrals Trick

Here’s something that took me way too long to learn: pure gray has no personality.

tinted-vs-pure.css
/* DEAD - feels cold and lifeless */
--gray-100: oklch(95% 0 0);
/* ALIVE - subtle warmth, feels cohesive */
--gray-100: oklch(95% 0.005 250); /* Tinted with brand hue */

By adding just a tiny amount of chroma (0.005-0.015) using your brand’s hue value, you create subconscious cohesion between your brand colors and your neutrals. The UI feels more “designed” without being overtly colorful.

Dark Mode Is Not Inverted Light Mode

This was my next mistake. I thought dark mode was just “flip the colors.”

It’s not.

Dark mode requires different thinking:

dark-mode.css
@media (prefers-color-scheme: dark) {
:root {
/* Lighter surfaces for depth, not shadows */
--surface-base: oklch(15% 0.008 250);
--surface-elevated: oklch(20% 0.008 250);
--surface-overlay: oklch(25% 0.008 250);
/* Slightly desaturated accents */
--color-primary: oklch(65% 0.12 250); /* Reduced chroma */
/* Lighter text needs less contrast in dark mode */
--text-primary: oklch(90% 0.005 250);
--text-secondary: oklch(70% 0.005 250);
}
/* Reduce font weight slightly */
body {
font-weight: 400; /* Instead of 500 */
}
}

Key differences from light mode:

  1. Depth through lighter surfaces, not darker shadows
  2. Slightly desaturated colors (high chroma vibrates on dark backgrounds)
  3. Reduced font weight (thin strokes disappear against dark backgrounds)
  4. Lower contrast requirements (WCAG accounts for this)

Contrast Ratios Become Predictable

With HSL, I was constantly checking contrast ratios manually. With OKLCH, I can predict them:

contrast-predictions.css
/*
* At 50% lightness with 0.01 chroma:
* - Against white (100%): ~3:1 contrast
* - Against 90% surface: ~2.5:1 contrast
*
* At 40% lightness:
* - Against white: ~4.5:1 contrast (WCAG AA for normal text)
*
* At 30% lightness:
* - Against white: ~7:1 contrast (WCAG AAA)
*/

This predictability means I can build color systems programmatically and trust that accessibility will work out.

Dangerous Color Combinations to Avoid

While we’re on accessibility, here are combinations I’ve learned to avoid:

accessibility-fails.txt
LIGHT GRAY TEXT ON WHITE
- Example: oklch(70% ...) on oklch(98% ...)
- The #1 accessibility fail I see in production
- Use oklch(40% ...) or darker for text on light backgrounds
GRAY TEXT ON COLORED BACKGROUNDS
- Looks washed out because of simultaneous contrast
- Instead, use a tinted version of the background color
RED TEXT ON GREEN (and vice versa)
- 8% of men have red-green color blindness
- Use shapes or icons in addition to color
BLUE TEXT ON RED (and vice versa)
- Creates visual vibration
- The colors compete for visual dominance

Browser Support and Fallbacks

OKLCH has excellent browser support in 2024/2025:

fallbacks.css
/* Fallback for older browsers */
:root {
--color-primary: hsl(220, 70%, 50%);
--color-primary: oklch(60% 0.15 250);
}
/* Or use @supports */
@supports (color: oklch(50% 0.1 200)) {
:root {
--color-primary: oklch(60% 0.15 250);
}
}

All major browsers support OKLCH now. The fallbacks are mostly for IE11 and very old browser versions.

Real-World Example: Building a Button System

Here’s how I build a complete button system with OKLCH:

button-system.css
:root {
/* Base values */
--btn-bg: oklch(60% 0.15 250);
--btn-bg-hover: oklch(55% 0.18 250); /* Darker + more saturated */
--btn-bg-active: oklch(50% 0.16 250);
--btn-text: oklch(98% 0.005 250);
/* Disabled state */
--btn-bg-disabled: oklch(70% 0.03 250);
--btn-text-disabled: oklch(50% 0.01 250);
}
.btn {
background: var(--btn-bg);
color: var(--btn-text);
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.375rem;
font-weight: 500;
transition: background 0.15s ease;
}
.btn:hover:not(:disabled) {
background: var(--btn-bg-hover);
}
.btn:active:not(:disabled) {
background: var(--btn-bg-active);
}
.btn:disabled {
background: var(--btn-bg-disabled);
color: var(--btn-text-disabled);
cursor: not-allowed;
}

Notice how the hover state increases both lightness AND chroma? That’s intentional—it makes the button feel more “alive” on interaction. The disabled state reduces chroma dramatically, making it clearly inactive without changing hue.

Why This Matters for Your Projects

After switching to OKLCH, my color workflows changed:

  1. Consistent palettes - No more manual tweaking per color
  2. Predictable contrast - I can calculate WCAG compliance mathematically
  3. Better dark mode - Systematic approach instead of one-off adjustments
  4. Faster iteration - Change one hue value, update entire palette
  5. Team communication - “Increase lightness by 10%” means the same thing for every color

The biggest win? I stopped fighting my color system and started trusting it.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments