UI/UX Atlas
Color Intermediate

Color Models: RGB, HSL, LCH & OKLCH

Understanding color models — from the hardware-native RGB to the perceptually-uniform OKLCH — unlocks principled, accessible palette work that survives dark mode and scales.

7 min read

The full lesson

Every color you write in a design tool or stylesheet is a coordinate in a mathematical color model. The model you choose determines whether your palette scales predictably, whether tonal steps look visually even, and whether the browser interpolates gradients without ugly hue drift. This lesson walks through the four models you will encounter most — RGB, HSL, LCH, and OKLCH — and explains why modern UI work has shifted toward perceptually-uniform spaces.

RGB: The Hardware Layer

RGB describes color as three intensity channels — Red, Green, Blue — each from 0 to 255 (8-bit) or 0.0 to 1.0 (normalized). It maps directly to how display pixels work. Each pixel is a cluster of sub-pixels emitting those three primaries at varying intensity.

color: rgb(99 102 241);        /* Indigo-500 */
color: rgb(99 102 241 / 0.8);  /* 80% opacity */

Why RGB falls short for design work

RGB is additive and device-dependent. Equal numeric steps do not produce equal visual jumps. Moving from rgb(0 0 0) to rgb(50 0 0) looks like a dramatic leap, while moving from rgb(200 0 0) to rgb(250 0 0) looks subtle — even though the raw delta is identical. This non-linearity has two practical consequences:

  • Hand-picking “50 units darker” produces inconsistent tonal steps.
  • Mixing two RGB colors via linear interpolation often produces a muddy, desaturated midpoint — the classic gray mud in gradients between complementary hues.

RGB is the output format browsers ultimately consume, but it is a poor authoring format.

HSL: The Designer’s Shorthand

HSL remaps RGB into three more intuitive axes:

  • Hue (H) — angle on the color wheel, 0°–360°
  • Saturation (S) — vividity, 0%–100%
  • Lightness (L) — perceived brightness, 0% (black) to 100% (white)
color: hsl(239 84% 67%);        /* same Indigo-500 */
color: hsl(239 84% 67% / 0.8);

HSL made it much easier to reason about colors in CSS. Need a lighter tint? Increase L. Need a muted shade? Decrease S. Design tools throughout the 2010s were built around this model, and Sass’s lighten() / darken() functions operated on the L channel.

The perceptual uniformity problem

HSL’s fatal flaw is that lightness is not perceptually uniform. A pure yellow (hsl(60 100% 50%)) and a pure blue (hsl(240 100% 50%)) share the same L value of 50%, yet yellow looks dramatically brighter to the human eye. This happens because HSL is simply a geometric remapping of RGB — it inherits RGB’s perceptual inconsistencies.

The practical fallout:

  • Tonal scales built in HSL (for example, a 50-step ramp by incrementing L) have uneven perceived jumps, especially through the yellow and green bands.
  • Contrast ratios computed via WCAG formulas do not correlate with HSL lightness, so you cannot use HSL L to reason about accessibility.
  • Sass darken() / lighten() routinely produce muddy results because they shift only L while S stays fixed.

LCH: The Perceptual Leap

LCH is derived from the CIELAB color space. CIELAB was designed by the CIE (International Commission on Illumination) specifically to be perceptually uniform — meaning equal numeric steps produce equal visual jumps. LCH’s axes are:

  • Lightness (L) — 0 (black) to 100 (white), now tracking actual perceived brightness
  • Chroma (C) — colorfulness or saturation, 0 (gray) to ~150+ depending on gamut
  • Hue (H) — the same 0°–360° wheel concept as HSL
/* LCH in modern CSS */
color: lch(55% 55 264);

What perceptual uniformity buys you

Because L in LCH tracks actual human luminance perception, two colors with the same L value genuinely look the same brightness. This means:

  1. Algorithmically even tonal scales — generate a 9-step palette by incrementing L in equal amounts and the steps will look visually consistent across all hues.
  2. Predictable contrast — L correlates well (though not perfectly) with the relative luminance used in WCAG contrast calculations.
  3. Hue-preserving interpolation — gradients and animations interpolated in LCH maintain saturation through the midpoint instead of desaturating.

One catch: LCH’s chroma axis is not bounded. Certain L+C+H combinations describe colors that exist in theory but are outside sRGB or even Display P3 gamut. You must clamp or gamut-map them.

OKLCH: The Current Best Practice

OKLCH is a refinement of LCH developed by Björn Ottosson in 2020 and adopted into CSS Color Level 4. It fixes two residual problems in LCH:

  • Hue linearity — LCH has a “hue shift” bug in blues: as chroma increases, blues drift toward purple. OKLCH corrects this.
  • Lightness predictability — OKLCH’s lightness channel (L from 0 to 1, or expressed as a percentage) is even more tightly correlated with perceived brightness than LCH’s.
/* OKLCH — native CSS, no polyfill needed in modern browsers */
color: oklch(62% 0.18 264);
/*           L    C    H  */

OKLCH browser support and tooling

As of 2026, OKLCH has full support in all modern browsers (Chrome 111+, Firefox 113+, Safari 15.4+). The @supports guard is optional but useful for older enterprise targets:

.badge {
  background: oklch(62% 0.18 264);
}
@supports not (color: oklch(0% 0 0)) {
  .badge {
    background: hsl(239 84% 67%); /* sRGB fallback */
  }
}

Design tools are catching up fast. Figma introduced OKLCH pickers in 2024. The oklch() function is now the recommended authoring format in the W3C Design Token Community Group (DTCG) token spec, where $type: "color" with OKLCH values provides a single source of truth that tools can convert downstream.

Building Tonal Scales: HSL vs. OKLCH

The difference between HSL and OKLCH becomes concrete when you generate a tonal scale. Consider building a 9-step blue palette from lightest to darkest.

StepHSL approachOKLCH approach
50hsl(220 100% 96%) — increment L by ~10%oklch(97% 0.04 264) — increment L by ~0.09
100hsl(220 100% 87%)oklch(91% 0.07 264)
500hsl(220 84% 55%)oklch(62% 0.18 264)
900hsl(220 90% 20%)oklch(28% 0.12 264)

The HSL scale tends to have a “bright plateau” in the middle range and then darkens abruptly. The OKLCH scale moves in visually even steps. Testing both against a white background in WCAG 2.2 AA contrast checks reveals that only OKLCH gives you a reliable heuristic: roughly steps 600 and above will pass 4.5:1 contrast on white.

Do

Build tonal scales in OKLCH by incrementing L in equal steps (e.g. 0.97 down to 0.15 across 9 steps) and holding H and C roughly constant. Verify WCAG 2.2 AA contrast at every intended text-on-background combination.

Don't

Use Sass darken() or manual HSL lightness tweaks to create tonal ramps. Equal L increments in HSL produce visually uneven steps, especially near yellow-green. You will ship a “muddy middle” palette.

Interpolation and Gradients

Color model choice matters enormously for gradients and animations. CSS linear-gradient and color-mix() both accept an optional interpolation space:

/* Vivid, hue-preserving gradient in OKLCH */
background: linear-gradient(
  in oklch,
  oklch(75% 0.2 30),
  oklch(65% 0.22 264)
);

/* The same endpoints in sRGB — desaturates through the middle */
background: linear-gradient(
  oklch(75% 0.2 30),
  oklch(65% 0.22 264)
);

The in oklch keyword routes interpolation through the OKLCH space. The result stays vivid from edge to edge. Without it, the browser interpolates in sRGB, and complementary-hue gradients pass through a desaturated gray band.

color-mix() follows the same pattern:

/* 50% mix in OKLCH space */
color: color-mix(in oklch, oklch(62% 0.18 264) 50%, oklch(75% 0.2 30));

OKLCH and Wide Gamut Displays

The sRGB color space covers roughly 35% of visible colors. Display P3 — used in most phones and laptops since around 2018 — covers about 50%. OKLCH’s chroma axis can describe colors in both spaces and beyond. This lets you write future-aware code:

.button-primary {
  /* P3 vivid blue for wide-gamut screens */
  background: oklch(55% 0.25 264);

  /* Browsers automatically gamut-map to sRGB on older screens */
}

No media query is required. The browser performs gamut mapping automatically per the CSS Color Level 4 spec. Colors outside the current display’s gamut are mapped to the closest in-gamut color rather than clipping unpredictably.

Choosing the Right Model for the Right Job

No single model wins in every context. Here is a practical decision guide:

Use caseRecommended modelReason
Design token authoring (primitive palette)OKLCHPerceptual uniformity; converts to any output format
CSS text color, background, borderOKLCH or oklch()Native, vivid, correct gamut mapping
Legacy browser fallback (IE, old Safari)hsl() or hexCompatibility; include as @supports fallback
Gradient / color-mix interpolationin oklchAvoids gray mudband
Reading/debugging a color in devtoolsHSLHuman-readable; most devtools show HSL on hover
Image processing / GPU shadersLinear sRGBPhysically correct light math

Summary of Key Differences

PropertyRGBHSLLCHOKLCH
Perceptually uniformNoNoMostlyYes
Native CSS supportYesYesYesYes (Chrome 111+)
Hue linearityN/AApproximateSome shift in bluesCorrected
Good for tonal scalesNoNoYesYes
Wide-gamut authoringNo (sRGB only)No (sRGB only)YesYes
Gradient fidelityPoorPoorGoodBest