Home CSS

CSS color spaces and contrast

CSS Color 4 brings device-independent colour spaces (display-p3, lch, oklch) and the relative-colour syntax. Picking colours in a perceptually uniform space makes contrast tuning predictable.

Why colour space matters

CSS colours have historically been authored in sRGB, the colour space of the web of 2000. Modern displays cover wider gamuts — display-p3 is roughly 25% larger than sRGB and is the native space of most laptops and phones shipped after 2020. CSS Color 4 introduces new colour functions that let authors target those gamuts and recover the device-independent values when authoring.

The practical implications:

  • Brand colours can be more saturated without leaving the gamut on capable displays.
  • Contrast ratios shift when colours migrate between spaces.
  • Gradients are more uniform in perceptual spaces (lch, oklch) than in sRGB.

The new colour functions

:root {
  --brand: oklch(54% 0.18 295);            /* perceptual */
  --brand-p3: color(display-p3 0.48 0.31 0.81); /* device gamut */
  --brand-lab: lab(50% 30 -50);            /* perceptual */
}
SpaceUse it for
rgb() / #rrggbbCompatibility with legacy stylesheets.
hsl()Authoring in hue + saturation; not perceptually uniform.
lab()Device-independent perceptual colour.
lch()lab() with polar (hue) coordinates.
oklab() / oklch()Modernised, more uniform variants of lab/lch.
color(display-p3 ...)Targeting wide-gamut displays.
color(rec2020 ...)Targeting HDR pipelines.

In practice, authors use oklch() for design tokens and color(display-p3 ...) for hand-tuned brand accents. The relative colour syntax (oklch(from var(--brand) calc(l + 0.1) c h)) makes shade ramps trivial.

Computing contrast across spaces

Contrast for accessibility (WCAG 2.x) is defined on sRGB-relative luminance. When colours are authored in P3, the user agent computes the actual displayed colour and applies the formula. WCAG 3 (in Working Draft) specifies APCA, a perceptual contrast metric that better tracks human sensitivity at low luminance; it is not yet normative for any AA conformance level, but several design systems adopt it as a secondary check.

For now, verify ratios in sRGB:

function relLum(rgb) {
  // rgb in 0..1 sRGB
  const ln = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
  return 0.2126 * ln(rgb[0]) + 0.7152 * ln(rgb[1]) + 0.0722 * ln(rgb[2]);
}
function contrast(a, b) {
  const [L1, L2] = [relLum(a), relLum(b)].sort((x, y) => y - x);
  return (L1 + 0.05) / (L2 + 0.05);
}

Forced-colors mode

Operating systems offer a high-contrast mode (Windows High Contrast, macOS Increase Contrast, GNOME high-contrast). User agents map this to forced-colors: active in CSS. In forced-colors mode, the user agent overrides author colours with system colours; authors should respect this rather than fight it:

@media (forced-colors: active) {
  .button {
    border: 2px solid CanvasText;
    background: ButtonFace;
    color: ButtonText;
  }
}

color-scheme: light dark lets the user agent pick scrollbars and form-control native colours appropriately.

Browser engine support

  • oklch() / oklab() — interop 2023 across Chromium (Blink), WebKit, and Gecko.
  • color(display-p3 ...) — interop 2022 across all three engines.
  • Relative colour syntax — interop 2024.
  • forced-colors media query — interop 2022.

Common pitfalls

  • Colour token in sRGB; gradient in P3. Mixing produces visible banding. Pick a single space for the design system.
  • Brand oklch() with c exceeding the sRGB gamut. Some users on sRGB-only displays will see a fall-back colour. Set both an sRGB and a wide-gamut value when this matters.
  • color: red for error text. Pure red on a white background meets AA (5.25:1) but pure red on dark grey fails. Always test the actual pair.
  • prefers-contrast: more ignored. A system-level user request for higher contrast deserves a stylesheet branch.