Back to Blog
Reference 2026-04-23

Color Systems: RGB, HSL, and OKLCH

Why OKLCH is replacing HSL in 2026 design systems.

Design systems are migrating from HSL to OKLCH, and the reason is not fashion. HSL has a measurable defect — its lightness axis lies to the human eye — and OKLCH fixes it with a model actually derived from perception experiments. Understanding the mechanics tells you when the switch pays off and where the sharp edges are.

RGB and Hex: Hardware Coordinates

color: #ff6633;

color: rgb(255 102 51);

An sRGB triplet describes emitter intensities, gamma-encoded so that the 8 bits per channel are spent where eyes are most sensitive. As a storage and interchange format it is fine. As a design manipulation space it is useless: there is no channel you can adjust to get "the same hue, slightly darker." Any derived palette built by nudging RGB channels drifts in hue and saturation simultaneously.

HSL: A Geometric Transform, Not a Perceptual One

color: hsl(15 100% 60%);

HSL rearranges the RGB cube into a cylinder — hue as angle, saturation as radius, lightness as height. It is cheap to compute and easy to reason about, which made it the CSS workhorse for two decades. The defect: HSL lightness is a formula over RGB values, not a measure of perceived brightness. hsl(60 100% 50%) (yellow) and hsl(240 100% 50%) (blue) share L=50%, yet the yellow is dramatically brighter to any human observer. Consequences compound in a palette: a "uniform" lightness ramp generated in HSL has visibly uneven steps, and hue rotation at constant L changes perceived brightness — which is why HSL-generated category colors look chaotic on charts.

OKLCH: Perceptual Coordinates

OKLCH is the cylindrical form of OKLab, published by Björn Ottosson in 2020 and adopted in CSS Color Level 4. It was fitted numerically against modern color-appearance data, correcting both CIELAB's blue-hue distortion and HSL's lightness lie.

color: oklch(0.7 0.15 45);

  • L — perceptual lightness, 0 to 1. Two colors with equal L genuinely look equally bright.
  • C — chroma, 0 (gray) upward, in practice topping out near 0.37 for the most saturated displayable colors. Unlike HSL saturation it is absolute, not relative to the gamut.
  • H — hue angle in degrees, perceptually spaced.

The property that changes daily work: axes are independent. Change L and the hue does not drift. Rotate H at fixed L and C, and every resulting color has the same visual weight.

--brand-100: oklch(0.95 0.04 250);

--brand-300: oklch(0.80 0.10 250);

--brand-500: oklch(0.65 0.15 250);

--brand-700: oklch(0.45 0.12 250);

--brand-900: oklch(0.28 0.08 250);

That is a full tint-shade scale from one hue number, with steps that look even — the thing HSL could never deliver. CSS Color 5's relative color syntax makes derivation declarative: oklch(from var(--brand-500) calc(l + 0.1) c h) is "the brand color, one step lighter."

Gamut: The New Edge Case

OKLCH describes colors regardless of whether a screen can show them. High-chroma values exceed sRGB, and some exceed Display P3. Browsers apply gamut mapping to the nearest displayable color, but relying on it means shipping colors you have never actually seen. Practical guardrails:

  • Keep systematic palettes at moderate chroma (roughly 0.15 and below for text-adjacent colors) so all steps survive in sRGB.
  • Reserve high-chroma P3 colors for accents, gated behind @media (color-gamut: p3).
  • Check each token in a converter rather than trusting the math.

Browser support has been universal in evergreen browsers since 2023. For legacy targets, author in OKLCH and let PostCSS emit RGB fallbacks; the pattern of a plain declaration followed by an @supports (color: oklch(0 0 0)) override also works without tooling.

Two Mistakes to Avoid

  • Equal ΔL does not mean WCAG-compliant contrast. WCAG 2.x contrast ratios use a different luminance formula, so an OKLCH-derived pair that looks fine can still fail the checker — and vice versa (the flaw APCA aims to fix). Always measure contrast separately.
  • Interpolating in the wrong space. Gradients and color-mix() default to sRGB in some contexts, producing gray dead zones between complementary colors. color-mix(in oklch, var(--a), var(--b)) interpolates perceptually.

Migrating an Existing Palette

Converting a legacy palette starts with a mechanical step: translate each hex token to OKLCH, then look at the numbers. Most hand-tuned palettes reveal their inconsistency immediately — the brand-500 tokens across hues rarely share an L value, which explains why some always looked "heavier." The pragmatic path: keep existing hex values as-is for released UI, define new tokens in OKLCH with harmonized L and C per step, and swap components over screen by screen. For dark mode, resist simple L inversion: dark themes need reduced chroma as well as flipped lightness, because high-chroma colors bloom and vibrate on dark backgrounds. A light-mode oklch(0.55 0.15 250) pairs with a hand-adjusted dark variant like oklch(0.75 0.10 250), not one computed by formula alone.

Compare the same color across hex, RGB, HSL, and OKLCH with the Color Picker at sdk.is/color-picker — the fastest way to build intuition for chroma values is to drag the sliders and watch the numbers.