30

Color in CSS: Modern Color in the Browser

For two decades CSS color meant hex codes and rgb() - a flat box of sRGB crayons. That era is over. Modern CSS can blend colors with color-mix(), derive variants with relative color syntax, reach beyond sRGB with color() and Display-P3, and finally describe color in a perceptually even way with oklch(). This is the hands-on guide to the color toolkit your browser already ships.

Digital · 66 4 Live Demos ~30 min read Web platform
color-mix()
Blend in any space
from
Relative color syntax
P3
Wider-gamut displays
oklch()
Perceptually even
01

Color in CSS grew up

The original CSS color model was simple and small: named colors, #rrggbb hex, and rgb() - all of it locked inside the sRGB gamut and all of it numeric. If you wanted a color 10% lighter, you did the arithmetic yourself or reached for a preprocessor like Sass. If you wanted a color your screen could show but sRGB could not name, you were out of luck.

CSS Color Level 4 and 5 changed all of that, and the features have quietly shipped across every major browser. You can now compute with color directly in the stylesheet: mix two colors in a color space of your choosing, build a new color from the channels of an existing one, address wide-gamut displays, and specify color in spaces - lab(), lch(), oklab(), oklch() - that match how people actually see. CSS stopped being a list of crayons and became a small color engine.

Why this matters beyond novelty: design systems live and die on derived color - hover states, disabled states, tints, shades, dark-mode variants. Doing that derivation in a perceptual space, in the browser, means your whole palette stays even and accessible from one source color, with no build step.
02

Beyond hex: the new notations

Before the verbs (mixing, deriving) come the nouns - the color notations themselves. Modern CSS accepts a whole family, and knowing which space each one lives in is the key to using them well. They fall into three groups: legacy sRGB, ergonomic cylinders, and perceptual spaces.

rgb() / hex
The originals. Device sRGB, gamma-encoded. Fine for fixed brand colors; poor for any kind of math because the numbers are not perceptual.
hsl() / hwb()
Cylindrical reparametrizations of sRGB. Friendlier to author by hand, but their "lightness" is not perceptual - 50% yellow dwarfs 50% blue.
lab() / lch()
CIELAB and its polar form. Perceptual and device-independent, anchored to the D50 white point. Great for measured color and large gamuts.
oklab() / oklch()
Ottosson's improved perceptual spaces. Better hue uniformity than CIELAB and built for screens - the modern default for UI color and gradients.
color()
The escape hatch to explicit color spaces: color(display-p3 …), color(rec2020 …), color(srgb-linear …), and more.
Channel keywords
Inside any of these, the channel names (r g b, l c h, l a b) become usable references - the basis of relative color syntax in section 4.

The mental model: sRGB notations describe a device; Lab/OKLab notations describe a perception. When you compute with color - interpolating, lightening, building scales - you almost always want to do it in a perceptual space and only convert back to a device color at the very end.

03

Blending with color-mix()

color-mix() takes two colors, a percentage, and - crucially - a color space to mix in: color-mix(in oklch, #2f6df6 60%, #f6c12f) means "60% of the blue, 40% of the gold, interpolated through OKLCH." The space is not a detail; it changes the path the blend takes. Mix blue and yellow in srgb and you pass through a dead gray midpoint; mix the same pair in hsl and you swing through green; mix in oklch and you get an even, controlled transition. Try all three.

Interactive 01 · color-mix()

The same two colors, three interpolation spaces

Pick two endpoint colors and the space to mix in. The strip shows the full blend from the first color to the second; the marker sits at your chosen percentage. Switch the space and watch the path change - srgb often dips through mud, hsl detours through other hues, oklch stays clean. The generated CSS updates live.

color-mix(in oklch, #2f6df6 50%, #f6c12f)
The everyday win: color-mix(in oklch, var(--brand) 85%, black) is a rock-solid "slightly darker brand" - and … 85%, white) a matching tint. One source color, a whole consistent set of states, no Sass.
04

Relative color syntax

color-mix() blends toward another color; relative color syntax lets you reach inside a color and rewrite its channels. The shape is oklch(from <color> L C H): the from keyword exposes the source's channels as the keywords l, c, and h, which you can pass through untouched or wrap in calc(). Want the brand color but 10% lighter? - oklch(from var(--brand) calc(l + 0.1) c h). Keep lightness and hue, halve the chroma for a muted version? - oklch(from var(--brand) l calc(c / 2) h). Adjust the channels and read the generated rule.

Interactive 02 · Relative color syntax

Derive a new color from a source's channels

The left swatch is your source color; the right is the derived result. The three controls map to the OKLCH channels: shift lightness, scale chroma, rotate hue. The generated oklch(from …) rule is exactly what you would paste into a stylesheet - and if a requested color falls outside sRGB, the readout tells you the chroma the browser would clip it to.

05

Wide gamut and the color() function

Most phones and laptops sold today have Display-P3 screens - a gamut roughly 25% larger than sRGB, with noticeably more saturated reds and greens. sRGB hex literally cannot name those colors. The color() function can: color(display-p3 1 0 0) is a P3 red far more vivid than #ff0000. You can even guard it with @media (color-gamut: p3) to serve the punchy version only where it will show.

The flip side is gamut mapping. When you ask for a color the target display can't produce - say a very high-chroma oklch() on an sRGB screen - the browser has to bring it back into range. The naive approach clips each RGB channel independently, which shifts both hue and lightness. The CSS spec instead reduces chroma while holding lightness and hue, which preserves the color's identity. See the difference.

Interactive 03 · Gamut mapping

When a color is too vivid for the screen

Dial up an OKLCH color. When its chroma exceeds what sRGB can show, the left half is the naive per-channel clip (notice it can drift in hue and lightness) and the right half is chroma-reduction mapping (same lightness, same hue, only less saturated). The bar shows requested chroma versus the sRGB ceiling at this lightness and hue.

inside sRGB
oklch(70% 0.2 150)
06

Why oklch() is the new default

Here is the single most useful thing to internalize about modern CSS color: hsl() lightness is a lie, and oklch() lightness is honest. In HSL, every hue claims the same "50% lightness," yet hsl(60 100% 50%) (yellow) is blinding while hsl(240 100% 50%) (blue) is nearly black. Build a palette by sweeping HSL hue at fixed lightness and it lurches between bright and dark. Do the same in OKLCH and every swatch holds the same perceived lightness - so contrast stays predictable and the set looks like a family.

Interactive 04 · HSL vs OKLCH lightness

Sweep the hue wheel at "constant" lightness

Both rows sweep hue from 0° to 360°. The top row is hsl(h 100% L); the bottom is oklch(L 0.13 h). The white line over each row traces measured luminance. The HSL line heaves up and down - bright yellows, dark blues - while the OKLCH line stays nearly flat. The ΔY readout is the spread between the lightest and darkest point of each row: smaller is more even.

Rule of thumb: author UI palettes, gradients, and color scales in oklch(); keep hsl() for quick one-off tweaks where perceptual evenness doesn't matter; reserve hex for fixed, externally-specified brand colors.
07

Best practices and pitfalls

The new functions are powerful, but they reward a little discipline. A handful of habits keep a modern-color stylesheet predictable across browsers, gamuts, and themes.

Mix in oklch, not srgb
Default color-mix() to in oklch for tints, shades, and gradients - sRGB interpolation muddies mid-tones.
Derive, don't hard-code
Build hover/active/disabled states with relative color or color-mix() from one token, so a brand change ripples everywhere.
Guard wide gamut
Serve color(display-p3 …) behind @media (color-gamut: p3) with an sRGB fallback first.
Expect gamut mapping
High-chroma oklch() may render less saturated on sRGB. Test on a real sRGB screen; don't assume the slider value is what ships.
Keep a fallback
Put a plain hex/rgb() declaration before a modern one so old engines get something usable, then let the cascade upgrade it.
Re-check contrast
Even oklch lightness is not a contrast guarantee - run derived text and background pairs through a real WCAG contrast check.
"Hex told the screen what voltage to use. oklch() tells the eye what to feel - and lets the browser worry about the voltage. That inversion is the whole story of modern CSS color." Editorial summary · from device to perception
Browser support, briefly: oklch(), oklab(), color(), and color-mix() are supported across current Chrome, Edge, Safari, and Firefox; relative color syntax landed last and is now broadly available in up-to-date browsers. As always, lead with a fallback and treat the modern value as a progressive enhancement.
08

Test your understanding

Six questions on color-mix(), relative color syntax, wide gamut, gamut mapping, and oklch(). Instant feedback, no scores recorded - a wrong answer comes with a short explanation.

Quick check

Loading…
 
Question 1 of 6
09

Continue your journey

Modern CSS color is the practical front end of a deeper stack - the perceptual models behind it, the gamuts it targets, and the systems it builds.