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.
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 — wherergb()andhsl()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() | |
|---|---|---|---|
| Axes | Red, green, blue | Hue, saturation, lightness | Lightness, chroma, hue |
| Perceptually uniform | No | No | Yes |
| Gamut | sRGB only | sRGB only | Wide (P3 and beyond) |
| Intuitive for scales | No | Mostly | Yes |
| Browser support | Universal | Universal | Modern 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()orhsl()fallback before theoklch()declaration; CSS lets the last valid, understood value win, so browsers that don’t recognizeoklch()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 invertinghsl()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.
Tagged
Keep reading
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.
Takina · · 4 min read CSS Relative Color Syntax: Deriving Colors from Colors
Relative color syntax lets CSS derive a new color from an existing one by adjusting its channels directly, no preprocessor or JS required.
Takina · · 4 min read What Is CSS Subgrid? Nested Grid Alignment Explained
CSS subgrid lets a nested grid item inherit its parent's track sizing, so children can align to the same columns or rows across unrelated containers.