cd ../writing
// css · color science

Color spaces in CSS — why your gradients look muddy.

For 30 years, web color was a hack. #rrggbb mapped to a 1996 color space (sRGB) designed for CRT monitors, used a non-perceptual model that made interpolation broken, and ignored the existence of every modern display. In the last two years, CSS finally caught up. OKLCH, Display P3, color-mix(), and relative color syntax now ship in every browser. Here's what they actually fix and how to use them.

2023+ CSS Color 4 universal browser support OKLCH · P3 · color-mix © use anywhere

01What's wrong with the gradient you just made

Open any CSS tutorial from 2010 to 2022. Pick a "rainbow gradient" example. Render it. Notice the dirty grey-brown stripe in the middle? That's not a design choice. That's the math.

When you write linear-gradient(blue, yellow), the browser interpolates between the two endpoints in sRGB. sRGB is a non-linear space where the numeric midpoint between two saturated colors is darker and less saturated than either endpoint. The visual midpoint between vivid blue and vivid yellow should be vivid green. In sRGB, you get olive-gray.

sRGB (default)linear-gradient(blue, yellow)
OKLCHlinear-gradient(in oklch, blue, yellow)

Same colors. Same gradient. Different math. The right one is just visibly right. The wrong one has been the default for 30 years and is the reason every web designer has accepted that gradients look muddy.

02OKLCH — the color space you should use

OKLCH (lightness, chroma, hue, designed by Björn Ottosson in 2020) is the modern color space CSS gave us. Three numbers, each one perceptual:

  • L — Lightness, 0% to 100%. Perceptually uniform: 50% lightness looks the same brightness regardless of hue. In HSL, this isn't true — 50% yellow looks much brighter than 50% blue.
  • C — Chroma, 0 to ~0.4. Saturation, but real: 0 is grey, max is the most colorful that hue can be on the display.
  • H — Hue, 0 to 360 degrees. Same as HSL's hue but with corrected geometry — adjacent hues feel adjacent.
✓ writing OKLCH
.brand {
  color: oklch(60% 0.22 280);   /* lightness, chroma, hue */
}

/* Lighter version: only change L */
.brand-light {
  color: oklch(85% 0.10 280);
}

/* Add alpha with / */
.brand-faded {
  color: oklch(60% 0.22 280 / 0.4);
}

The killer feature: generating consistent variations is now math, not eyeballing. To make a lighter variant of a brand color, you change L. To make a more muted variant, you change C. The hue stays put. The "tints and shades" workflow that Figma made famous is finally one-line CSS.

03Display P3 — the wider gamut

sRGB was designed for 1996 CRTs. Modern displays — every iPhone since 2016, every M-series Mac, every recent OLED TV — cover Display P3, a wider color gamut. Roughly 50% more visible colors than sRGB, especially in saturated reds and greens.

For 25 years, web content couldn't use those colors. CSS shipped one color space, and that space was sRGB. In 2023, CSS Color 4 added explicit color-space syntax:

✓ wide-gamut colors
.accent {
  /* sRGB red — desaturated on modern displays */
  color: rgb(255 0 0);

  /* Display P3 red — significantly more saturated */
  color: color(display-p3 1 0 0);
}

On a P3 display, the second is visibly more vivid. On an sRGB display, the browser maps it back to the nearest sRGB color — no harm done. This is pure progressive enhancement: modern users see better colors, older users see what they always saw.

Should every site use P3? Not necessarily. P3 colors that fall outside sRGB get gamut-mapped on older displays, which can shift them perceptually. For brand-critical colors where consistency across devices matters, stay in sRGB. For accents and decorative gradients where "more vivid where possible" is the goal, use P3.

04color-mix() — the function that replaced Sass

For years, the only way to compute "this color, 20% darker" was Sass's darken() function or hand-tweaking hex values. color-mix() makes this a CSS primitive:

✓ color-mix recipes
/* Darken by mixing with black */
.btn-active {
  background: color-mix(in oklch, var(--brand) 85%, black);
}

/* Lighten by mixing with white */
.btn-hover {
  background: color-mix(in oklch, var(--brand) 90%, white);
}

/* Desaturate by mixing with grey */
.btn-disabled {
  background: color-mix(in oklch, var(--brand) 30%, gray);
}

/* Transparent overlay on brand */
.overlay {
  background: color-mix(in srgb, var(--brand) 30%, transparent);
}

Combined with custom properties, you can build a complete theming system from a single brand color, computed entirely in CSS, with no preprocessor. Same brand color, different lightness/saturation derivations, all maintained automatically.

05Relative color syntax — the precision tool

The most powerful (and most-overlooked) feature of CSS Color 4 is relative color syntax. You can derive one color from another with full math access to the components:

✓ relative color syntax
/* "Brand color but 30% lighter" */
.brand-light {
  color: oklch(from var(--brand) calc(l + 30%) c h);
}

/* "Brand color but desaturated" */
.brand-muted {
  color: oklch(from var(--brand) l calc(c * 0.4) h);
}

/* "Brand color but rotate hue 180°" — complement */
.brand-complement {
  color: oklch(from var(--brand) l c calc(h + 180));
}

The from keyword unpacks the source color into its three components (l, c, h), which you can then use in calc() expressions. This replaces almost everything Sass color functions did, with full type safety, native browser computation, and no build step.

06Gradient interpolation — the obvious win

The simplest payoff: write your gradients in OKLCH and they look right. No more muddy middle.

✓ proper gradient interpolation
/* Default (broken) */
.bad {
  background: linear-gradient(90deg, blue, yellow);
}

/* Modern: explicit interpolation space */
.good {
  background: linear-gradient(in oklch, blue, yellow);
}

/* Conic, radial — all support the syntax */
.ring {
  background: conic-gradient(in oklch, red, green, blue, red);
}

/* Long-hue rotation (the long way around the color wheel) */
.rainbow {
  background: linear-gradient(in oklch longer hue, red, red);
}

07Gotchas worth knowing

OKLCH chroma is unbounded. Values above ~0.4 are gamut-clipped. If you write oklch(60% 0.6 280), the browser clamps to the most-saturated displayable purple. This is usually fine but can surprise you when generating colors programmatically.

Hue is undefined at low chroma. A near-grey color has no meaningful hue — interpolating through grey can cause hue weirdness. The browser handles most cases, but be careful when animating colors through grey midpoints.

P3 fallbacks are automatic. Browsers gamut-map P3 colors to sRGB on non-P3 displays. The mapping isn't always what you want — sometimes a P3 red becomes a noticeably duller sRGB red. Use the @media (color-gamut: p3) query if you need branch logic.

Color contrast tools haven't all caught up. WCAG contrast formulas use sRGB luminance. If you compute contrast in P3 space, you might pass a checker tool while failing actual perception (or vice versa). Use sRGB-based contrast for accessibility validation regardless of what space you author colors in.

The shift that's underway

The web design industry standardized on sRGB and hex in 1996 and never moved. Print designers got CMYK and L*a*b*. Video colorists got Rec. 2020 and ACES. Web designers got #ff4081. For three decades.

OKLCH, P3, and color-mix don't just give us new tools. They put web color on technical equal footing with the other visual disciplines. The reason every Adobe gradient looks better than every CSS gradient was never about Adobe's talent — it was about access to color math the web didn't have.

Now we have it. Use it.