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 */
}
| Space | Use it for |
|---|---|
rgb() / #rrggbb | Compatibility 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-colorsmedia 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()withcexceeding 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: redfor 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: moreignored. A system-level user request for higher contrast deserves a stylesheet branch.