Articles

CSS Color Spaces: oklch() vs hsl() vs rgb()

oklch(), hsl(), and rgb() define color differently in CSS. How each works, why oklch is perceptually uniform, and when to reach for which.

Takina Takina · · 4 min read
Close-up of CSS source code on a screen

oklch(), hsl(), and rgb() are three different ways to describe the same colors in CSS, and they don’t agree on what “adjust the lightness” or “same distance apart” means. rgb() and hsl() are older, device-oriented models; oklch() is a newer, perceptually uniform color space designed so that numeric changes match how a human eye actually perceives brightness and hue.

rgb(): the display-native model

rgb() specifies red, green, and blue channel values directly — historically 0–255, with modern CSS also accepting percentages and a / alpha channel: rgb(59 130 246 / 0.8). It maps closely to how screens actually emit light, which is exactly why it’s a poor fit for design work: there’s no channel that corresponds to “lighter” or “more saturated.” Brightening a color means recalculating all three channels by hand or reaching for a preprocessor function.

hsl(): designed for humans, sort of

hsl() — hue, saturation, lightness — was CSS’s answer to that problem. hsl(217 91% 60%) reads far more intuitively: hue picks the color family (0–360°), saturation controls intensity, and lightness moves toward white or black. Bumping lightness by 10% to make a hover state is trivial in a way it isn’t with rgb().

The catch is that HSL’s lightness axis doesn’t match human perception. Pure yellow (hsl(60 100% 50%)) and pure blue (hsl(240 100% 50%)) share the same lightness value, but the yellow reads as dramatically brighter to the eye. Two colors “50% apart” in HSL lightness can look nowhere near equally different in a real interface, which makes it unreliable for generating consistent color scales — a shade ramp built by stepping HSL lightness evenly often looks uneven.

oklch(): perceptually uniform, wide gamut

oklch() — lightness, chroma, hue — is built on the Oklab color space, specifically designed so equal numeric steps produce equal perceived steps. oklch(65% 0.15 250) describes lightness as a percentage that actually tracks how bright the color looks, chroma as colorfulness (roughly analogous to saturation but unbounded), and hue in degrees.

Two practical wins fall out of this:

  • Predictable scales. Stepping lightness from 20% to 90% in fixed increments produces a ramp that looks evenly spaced, which is exactly what a design system’s shade palette needs.
  • Wide gamut support. oklch() can express colors outside sRGB — the P3 gamut increasingly common on modern displays — where rgb() and hsl() are capped to sRGB’s smaller range.

The tradeoff is legibility to a newcomer: chroma has no fixed maximum and its practical range varies by hue, so “0.15” doesn’t mean the same visual intensity at every hue the way “50% saturation” reads consistently in HSL.

Comparing the three

rgb()hsl()oklch()
AxesRed, green, blueHue, saturation, lightnessLightness, chroma, hue
Perceptually uniformNoNoYes
GamutsRGB onlysRGB onlyWide (P3 and beyond)
Intuitive for scalesNoMostlyYes
Browser supportUniversalUniversalModern browsers only

Where color-mix() fits in

Regardless of which space you author colors in, color-mix() lets you blend two colors at runtime — color-mix(in oklch, var(--brand) 80%, white) — and it matters which space the mix happens in: mixing in oklch avoids the muddy, desaturated middle tones that mixing in srgb or hsl can produce, because the interpolation follows the perceptually uniform axes instead of raw channel math.

Practical guidance

  • For a quick one-off color, hsl() is still perfectly fine and universally supported.
  • For a systematic palette — a design system’s full set of shades, tints, and semantic tokens — oklch() produces more consistent, predictable results, especially when paired with CSS custom properties to define the palette once and reference it everywhere.
  • If you need to support very old browsers, provide an rgb() or hsl() fallback before the oklch() declaration; CSS lets the last valid, understood value win, so browsers that don’t recognize oklch() simply keep the earlier declaration.
  • Color spaces interact with dark mode work naturally — swapping a lightness value in oklch() for a dark variant keeps the same hue and chroma, avoiding the muddy or oversaturated results that can come from just inverting hsl() lightness.

The takeaway

rgb() matches how displays emit color, hsl() makes hand-tuning a single color intuitive, and oklch() makes systematic color work — scales, blends, and wide-gamut palettes — actually predictable, because equal numeric steps correspond to equal perceived steps. For a design system’s palette, oklch() is worth the smaller learning curve; for a quick style tweak, hsl() still gets the job done.

Takina Takina · · 4 min read

Container Query Units: cqw, cqh, and cqi Explained

Container query units like cqw and cqi size elements relative to their containing element, not the viewport — here's how and when to use them.

#CSS #Web Development #Frontend