OKLCH Color Design Guide

8 min readUpdated 2026-07-05

A blue-500 tile and a yellow-500 tile should look equally strong. They rarely do. In HSL they share the same lightness number and feel like two different palettes. That is not a minor quirk. It is the problem that breaks token scales, makes dark mode recalibration take days, and forces teams to hand-tune every generated shade.

OKLCH attacks exactly that problem. L tracks what the eye actually sees. C controls how much color gets injected. H dials the hue around the circle. The result is a color space where changing one knob does not break the others. If you need accessible contrast steps, dark mode variants that do not look neon, or a chart palette where every series feels like it belongs to the same family, OKLCH gets you closer to the answer with fewer manual corrections.

This guide covers the practical side: how to read OKLCH notation, how to build a 10-stop scale from a brand hue, how to write fallback-safe CSS, and how to validate everything against WCAG contrast ratios.

How It Works

The lightness problem no one fixes early enough

In HSL, yellow at L=50% looks about twice as bright as blue at L=50%. Teams discover this when their "logically correct" generated palette ships and badges, alerts, and chart lines feel uneven. The usual fix is hand-tuning every stop, which melts days and still breaks when the palette reaches a new component. OKLCH separates perceived lightness into its own parameter so a brand-600 and warning-600 can land at the same visual weight without guessing.

OKLCH vs HSL: where the difference hits production first

| What breaks | HSL behavior | OKLCH behavior | |---|---|---| | Yellow vs blue at L=50% | Yellow looks much brighter | Both feel similar weight | | Saturation scaling | Tied to lightness model, uneven steps | Independent chroma knob, predictable steps | | Dark mode adaptation | Must hand-pick new saturation + lightness | Keep hue, reduce chroma by 10-25%, pick new L | | Gradient smoothness | Gray dead zones near saturation minima | Smooth transitions through low-chroma stops | | Gamut clipping | Random-looking fallbacks on wide-gamut displays | Browser clips chroma gracefully |

Real teams shipping OKLCH today

Grafana rebuilt their dashboard chart palette in OKLCH so status colors, series colors, and threshold lines feel calibrated. They export HEX for older browsers but treat the OKLCH source as canonical. Tailwind CSS v4 moved to OKLCH-first color definitions for every built-in palette. Adobe's Spectrum 2 design tokens reference OKLCH internally and convert to platform-specific formats at export time. Each team made the switch because the math saves more time than it costs.

Dark mode stops being a guessing game

A team that defines brand-600 as oklch(56% 0.18 255) in light mode can pick brand-600-dark as oklch(72% 0.14 255). Same hue, more lightness, less chroma. No inverting, no hand-tuning 20 stops, no "this accent glows like a neon sign on dark backgrounds." That predictability is why OKLCH adoption spikes whenever a design system adds dark mode support.

Chroma is the quiet superpower

Background tints should feel subtle. Interactive accents should pop. You can achieve both from the same hue by keeping chroma at 0.01-0.04 for surfaces and 0.16-0.24 for CTAs. The hue is identical, so the palette stays cohesive even when the contrast gap is large.

How It Works

The lightness problem no one fixes early enough

In HSL, yellow at L=50% looks about twice as bright as blue at L=50%. Teams discover this when their "logically correct" generated palette ships and badges, alerts, and chart lines feel uneven. The usual fix is hand-tuning every stop, which melts days and still breaks when the palette reaches a new component. OKLCH separates perceived lightness into its own parameter so a brand-600 and warning-600 can land at the same visual weight without guessing.

OKLCH vs HSL: where the difference hits production first

| What breaks | HSL behavior | OKLCH behavior | |---|---|---| | Yellow vs blue at L=50% | Yellow looks much brighter | Both feel similar weight | | Saturation scaling | Tied to lightness model, uneven steps | Independent chroma knob, predictable steps | | Dark mode adaptation | Must hand-pick new saturation + lightness | Keep hue, reduce chroma by 10-25%, pick new L | | Gradient smoothness | Gray dead zones near saturation minima | Smooth transitions through low-chroma stops | | Gamut clipping | Random-looking fallbacks on wide-gamut displays | Browser clips chroma gracefully |

Real teams shipping OKLCH today

Grafana rebuilt their dashboard chart palette in OKLCH so status colors, series colors, and threshold lines feel calibrated. They export HEX for older browsers but treat the OKLCH source as canonical. Tailwind CSS v4 moved to OKLCH-first color definitions for every built-in palette. Adobe's Spectrum 2 design tokens reference OKLCH internally and convert to platform-specific formats at export time. Each team made the switch because the math saves more time than it costs.

Dark mode stops being a guessing game

A team that defines brand-600 as oklch(56% 0.18 255) in light mode can pick brand-600-dark as oklch(72% 0.14 255). Same hue, more lightness, less chroma. No inverting, no hand-tuning 20 stops, no "this accent glows like a neon sign on dark backgrounds." That predictability is why OKLCH adoption spikes whenever a design system adds dark mode support.

Chroma is the quiet superpower

Background tints should feel subtle. Interactive accents should pop. You can achieve both from the same hue by keeping chroma at 0.01-0.04 for surfaces and 0.16-0.24 for CTAs. The hue is identical, so the palette stays cohesive even when the contrast gap is large.

Comparison & Reference

The lightness problem no one fixes early enough

In HSL, yellow at L=50% looks about twice as bright as blue at L=50%. Teams discover this when their "logically correct" generated palette ships and badges, alerts, and chart lines feel uneven. The usual fix is hand-tuning every stop, which melts days and still breaks when the palette reaches a new component. OKLCH separates perceived lightness into its own parameter so a brand-600 and warning-600 can land at the same visual weight without guessing.

OKLCH vs HSL: where the difference hits production first

| What breaks | HSL behavior | OKLCH behavior | |---|---|---| | Yellow vs blue at L=50% | Yellow looks much brighter | Both feel similar weight | | Saturation scaling | Tied to lightness model, uneven steps | Independent chroma knob, predictable steps | | Dark mode adaptation | Must hand-pick new saturation + lightness | Keep hue, reduce chroma by 10-25%, pick new L | | Gradient smoothness | Gray dead zones near saturation minima | Smooth transitions through low-chroma stops | | Gamut clipping | Random-looking fallbacks on wide-gamut displays | Browser clips chroma gracefully |

Real teams shipping OKLCH today

Grafana rebuilt their dashboard chart palette in OKLCH so status colors, series colors, and threshold lines feel calibrated. They export HEX for older browsers but treat the OKLCH source as canonical. Tailwind CSS v4 moved to OKLCH-first color definitions for every built-in palette. Adobe's Spectrum 2 design tokens reference OKLCH internally and convert to platform-specific formats at export time. Each team made the switch because the math saves more time than it costs.

Dark mode stops being a guessing game

A team that defines brand-600 as oklch(56% 0.18 255) in light mode can pick brand-600-dark as oklch(72% 0.14 255). Same hue, more lightness, less chroma. No inverting, no hand-tuning 20 stops, no "this accent glows like a neon sign on dark backgrounds." That predictability is why OKLCH adoption spikes whenever a design system adds dark mode support.

Chroma is the quiet superpower

Background tints should feel subtle. Interactive accents should pop. You can achieve both from the same hue by keeping chroma at 0.01-0.04 for surfaces and 0.16-0.24 for CTAs. The hue is identical, so the palette stays cohesive even when the contrast gap is large.

OKLCH token system with fallbacks + dark mode

/* ─── Source-of-truth tokens in OKLCH ─── */
:root {
  /* Brand scale: same hue (260), chroma rises toward interactive stops */
  --brand-50:   #f2f0ff; --brand-50:   oklch(97% 0.02 260);
  --brand-100:  #e4e1fc; --brand-100:  oklch(93% 0.04 260);
  --brand-300:  #b3acf7; --brand-300:  oklch(78% 0.10 260);
  --brand-500:  #6a5ce7; --brand-500:  oklch(56% 0.20 260);
  --brand-700:  #4438a3; --brand-700:  oklch(38% 0.17 260);
  --brand-900:  #1f1860; --brand-900:  oklch(22% 0.08 260);

  /* Semantic tokens */
  --color-success:  oklch(60% 0.14 145);
  --color-warning:  oklch(70% 0.16 80);
  --color-danger:   oklch(56% 0.18 28);
  --color-info:     oklch(60% 0.13 245);

  /* Surfaces — low chroma, light end */
  --surface-page:   oklch(98% 0.005 260);
  --surface-card:   oklch(100% 0 0);
}

/* ─── Dark mode: adjust L + reduce C, keep H ─── */
@media (prefers-color-scheme: dark) {
  :root {
    --brand-50: oklch(18% 0.03 260);
    --brand-100: oklch(24% 0.05 260);
    --brand-500: oklch(72% 0.16 260);
    --brand-700: oklch(82% 0.12 260);
    --surface-page: oklch(14% 0.02 260);
    --surface-card: oklch(18% 0.02 260);
  }
}

Copy and paste into your project — free to use.

dark mode

The lightness problem no one fixes early enough

In HSL, yellow at L=50% looks about twice as bright as blue at L=50%. Teams discover this when their "logically correct" generated palette ships and badges, alerts, and chart lines feel uneven. The usual fix is hand-tuning every stop, which melts days and still breaks when the palette reaches a new component. OKLCH separates perceived lightness into its own parameter so a brand-600 and warning-600 can land at the same visual weight without guessing.

OKLCH vs HSL: where the difference hits production first

| What breaks | HSL behavior | OKLCH behavior | |---|---|---| | Yellow vs blue at L=50% | Yellow looks much brighter | Both feel similar weight | | Saturation scaling | Tied to lightness model, uneven steps | Independent chroma knob, predictable steps | | Dark mode adaptation | Must hand-pick new saturation + lightness | Keep hue, reduce chroma by 10-25%, pick new L | | Gradient smoothness | Gray dead zones near saturation minima | Smooth transitions through low-chroma stops | | Gamut clipping | Random-looking fallbacks on wide-gamut displays | Browser clips chroma gracefully |

Real teams shipping OKLCH today

Grafana rebuilt their dashboard chart palette in OKLCH so status colors, series colors, and threshold lines feel calibrated. They export HEX for older browsers but treat the OKLCH source as canonical. Tailwind CSS v4 moved to OKLCH-first color definitions for every built-in palette. Adobe's Spectrum 2 design tokens reference OKLCH internally and convert to platform-specific formats at export time. Each team made the switch because the math saves more time than it costs.

Dark mode stops being a guessing game

A team that defines brand-600 as oklch(56% 0.18 255) in light mode can pick brand-600-dark as oklch(72% 0.14 255). Same hue, more lightness, less chroma. No inverting, no hand-tuning 20 stops, no "this accent glows like a neon sign on dark backgrounds." That predictability is why OKLCH adoption spikes whenever a design system adds dark mode support.

Chroma is the quiet superpower

Background tints should feel subtle. Interactive accents should pop. You can achieve both from the same hue by keeping chroma at 0.01-0.04 for surfaces and 0.16-0.24 for CTAs. The hue is identical, so the palette stays cohesive even when the contrast gap is large.

Testing & Standards

The lightness problem no one fixes early enough

In HSL, yellow at L=50% looks about twice as bright as blue at L=50%. Teams discover this when their "logically correct" generated palette ships and badges, alerts, and chart lines feel uneven. The usual fix is hand-tuning every stop, which melts days and still breaks when the palette reaches a new component. OKLCH separates perceived lightness into its own parameter so a brand-600 and warning-600 can land at the same visual weight without guessing.

OKLCH vs HSL: where the difference hits production first

| What breaks | HSL behavior | OKLCH behavior | |---|---|---| | Yellow vs blue at L=50% | Yellow looks much brighter | Both feel similar weight | | Saturation scaling | Tied to lightness model, uneven steps | Independent chroma knob, predictable steps | | Dark mode adaptation | Must hand-pick new saturation + lightness | Keep hue, reduce chroma by 10-25%, pick new L | | Gradient smoothness | Gray dead zones near saturation minima | Smooth transitions through low-chroma stops | | Gamut clipping | Random-looking fallbacks on wide-gamut displays | Browser clips chroma gracefully |

Real teams shipping OKLCH today

Grafana rebuilt their dashboard chart palette in OKLCH so status colors, series colors, and threshold lines feel calibrated. They export HEX for older browsers but treat the OKLCH source as canonical. Tailwind CSS v4 moved to OKLCH-first color definitions for every built-in palette. Adobe's Spectrum 2 design tokens reference OKLCH internally and convert to platform-specific formats at export time. Each team made the switch because the math saves more time than it costs.

Dark mode stops being a guessing game

A team that defines brand-600 as oklch(56% 0.18 255) in light mode can pick brand-600-dark as oklch(72% 0.14 255). Same hue, more lightness, less chroma. No inverting, no hand-tuning 20 stops, no "this accent glows like a neon sign on dark backgrounds." That predictability is why OKLCH adoption spikes whenever a design system adds dark mode support.

Chroma is the quiet superpower

Background tints should feel subtle. Interactive accents should pop. You can achieve both from the same hue by keeping chroma at 0.01-0.04 for surfaces and 0.16-0.24 for CTAs. The hue is identical, so the palette stays cohesive even when the contrast gap is large.

Pro Tips

Developer Perspective

The lightness problem no one fixes early enough

In HSL, yellow at L=50% looks about twice as bright as blue at L=50%. Teams discover this when their "logically correct" generated palette ships and badges, alerts, and chart lines feel uneven. The usual fix is hand-tuning every stop, which melts days and still breaks when the palette reaches a new component. OKLCH separates perceived lightness into its own parameter so a brand-600 and warning-600 can land at the same visual weight without guessing.

OKLCH vs HSL: where the difference hits production first

| What breaks | HSL behavior | OKLCH behavior | |---|---|---| | Yellow vs blue at L=50% | Yellow looks much brighter | Both feel similar weight | | Saturation scaling | Tied to lightness model, uneven steps | Independent chroma knob, predictable steps | | Dark mode adaptation | Must hand-pick new saturation + lightness | Keep hue, reduce chroma by 10-25%, pick new L | | Gradient smoothness | Gray dead zones near saturation minima | Smooth transitions through low-chroma stops | | Gamut clipping | Random-looking fallbacks on wide-gamut displays | Browser clips chroma gracefully |

Real teams shipping OKLCH today

Grafana rebuilt their dashboard chart palette in OKLCH so status colors, series colors, and threshold lines feel calibrated. They export HEX for older browsers but treat the OKLCH source as canonical. Tailwind CSS v4 moved to OKLCH-first color definitions for every built-in palette. Adobe's Spectrum 2 design tokens reference OKLCH internally and convert to platform-specific formats at export time. Each team made the switch because the math saves more time than it costs.

Dark mode stops being a guessing game

A team that defines brand-600 as oklch(56% 0.18 255) in light mode can pick brand-600-dark as oklch(72% 0.14 255). Same hue, more lightness, less chroma. No inverting, no hand-tuning 20 stops, no "this accent glows like a neon sign on dark backgrounds." That predictability is why OKLCH adoption spikes whenever a design system adds dark mode support.

Chroma is the quiet superpower

Background tints should feel subtle. Interactive accents should pop. You can achieve both from the same hue by keeping chroma at 0.01-0.04 for surfaces and 0.16-0.24 for CTAs. The hue is identical, so the palette stays cohesive even when the contrast gap is large.

Use OKLCH as the canonical format, export HEX fallbacks for legacy consumers. Edit in one place only.

Build scales by keeping hue and reducing chroma for lighter stops. A brand-50 at oklch(97% 0.02 260) feels like one family with brand-500 at oklch(56% 0.20 260).

Try It Yourself

Use these free tools to apply what you learned: