Lesson 18
Advanced Colour - OKLCH, Wide Gamut & Relative Colour Syntax
For 25 years, web colour meant hex (#ff6600), rgb(), and maybe hsl(). These all describe colours within the sRGB gamut - a limited range of colours defined in 1996. Modern displays (P3 screens on MacBooks, iPhones, etc.) can show about 50% more colours than sRGB can represent. And perceptually, sRGB tools are broken - "50% lightness" in HSL doesn't look half as bright to your eyes.
Modern CSS fixes both problems with new colour spaces and syntax.
The mental model: why oklch matters
OKLCH has three channels:
- L (Lightness) - 0% = black, 100% = white. Perceptually uniform: L=50 genuinely looks "half-bright" to human eyes.
- C (Chroma) - how saturated/vivid the colour is. 0 = grey, higher = more vivid. No fixed max (depends on the hue).
- H (Hue) - the colour angle on the colour wheel (0-360). 0=red, 90=yellow, 180=cyan, 270=blue.
The colour wheel - what hue angles mean
The hue wheel - 12 positions around 360 degrees (OKLCH)
/* Hue is an angle on the OKLCH colour wheel: */
0 = pink
30 = red
60 = orange
90 = yellow
120 = lime
150 = green
180 = cyan
210 = teal
240 = blue
270 = violet
300 = purple
330 = magenta
/* Colour relationships (design theory): */
/* Complementary: +180 (opposite on wheel) */
/* Analogous: +/-30 (neighbours) */
/* Triadic: +120 each (three equally spaced) */
Why not just HSL? HSL lies to you. hsl(60, 100%, 50%) (yellow) looks FAR brighter than hsl(240, 100%, 50%) (blue) - even though both claim "50% lightness." OKLCH's lightness is calibrated to human vision - same L value = same perceived brightness regardless of hue.
oklch() syntax
Same lightness (65%), same chroma (0.2), different hues - they look equally bright
/* oklch syntax */
color: oklch(65% 0.2 240);
/* L C H
| | └── Hue angle (0-360)
| └─────── Chroma (0 = grey, ~0.4 = max vivid)
└──────────── Lightness (0% = black, 100% = white)
*/
/* With alpha (transparency) */
color: oklch(65% 0.2 240 / 0.5); /* 50% transparent */
Comparison: HSL vs OKLCH at "same lightness"
HSL: all "50% lightness" - but yellow looks much brighter than blue
OKLCH: all 65% lightness - genuinely equal perceived brightness
This is why OKLCH matters for design systems: You can create a palette where all colours at the same lightness level are actually interchangeable as backgrounds without adjusting text contrast. In HSL, you'd need manual tweaks per hue.
Wide gamut - colours beyond sRGB
On a P3 display, the top row (P3 gamut) will appear more vivid than the bottom (sRGB). On sRGB displays, they'll look the same.
Wide gamut (P3):
sRGB equivalent (clamped):
/* Wide gamut - more vivid on P3 displays */
color: oklch(65% 0.3 25); /* a red more vivid than #ff0000 can express */
color: color(display-p3 1 0 0); /* pure P3 red */
/* Fallback for browsers/displays without wide gamut */
.vibrant {
color: rgb(255, 59, 48); /* sRGB fallback */
color: oklch(65% 0.3 25); /* wide gamut (used if supported) */
}
Graceful degradation: If a browser or display can't show a wide-gamut colour, it gamut maps - clamps it to the closest representable colour. The page doesn't break; it just looks slightly less vivid. You can safely use oklch everywhere.
Relative colour syntax - derive colours from other colours
One base colour → an entire tint/shade scale by modifying lightness
/* Relative colour syntax: oklch(from <origin> L C H) */
:root {
--brand: #3a86ff;
}
/* Lighten - keep chroma and hue, increase lightness */
--brand-100: oklch(from var(--brand) 95% c h);
--brand-200: oklch(from var(--brand) 85% c h);
/* Darken - decrease lightness */
--brand-700: oklch(from var(--brand) 35% c h);
/* Desaturate - reduce chroma */
--brand-muted: oklch(from var(--brand) l 0.05 h);
/* Shift hue - create complementary/analogous */
--complement: oklch(from var(--brand) l c calc(h + 180));
--analogous: oklch(from var(--brand) l c calc(h + 30));
The power here: You define ONE brand colour, and derive your entire palette mathematically. Change the brand colour → the whole palette updates. No manual tuning per shade. And because it's in oklch, the lightness steps look perceptually even.
Building a design token palette
A complete colour scale from one variable - all shades derived automatically
:root {
--brand: #e76f51;
/* Full palette from one colour */
--brand-50: oklch(from var(--brand) 95% 0.05 h);
--brand-100: oklch(from var(--brand) 88% 0.08 h);
--brand-200: oklch(from var(--brand) 78% 0.12 h);
--brand-300: oklch(from var(--brand) 68% 0.18 h);
--brand-400: oklch(from var(--brand) 58% 0.2 h);
--brand-500: oklch(from var(--brand) 50% 0.2 h);
--brand-600: oklch(from var(--brand) 40% 0.18 h);
--brand-700: oklch(from var(--brand) 30% 0.14 h);
--brand-800: oklch(from var(--brand) 22% 0.08 h);
--brand-900: oklch(from var(--brand) 15% 0.05 h);
/* Change --brand and the ENTIRE palette updates */
}
light-dark() - theme-aware colour in one line
light-dark() picks between two values based on the user's colour-scheme preference
light-dark() - my background is white in light mode, dark in dark mode.
:root {
color-scheme: light dark; /* opt into both modes */
}
.card {
background: light-dark(white, #1a1a2e);
color: light-dark(#1a1a2e, #f0f0f0);
border-color: light-dark(#e0e0e0, #333);
}
/* No @media (prefers-color-scheme) needed!
light-dark() automatically picks based on the active scheme */
light-dark() replaces the pattern of duplicate @media blocks for dark mode. Instead of defining every colour twice in separate media queries, you define it once with both values inline. Requires color-scheme: light dark on the root.
Implementing theme modes
/* CSS */
:root { color-scheme: light dark; } /* default: follow OS */
:root.force-light { color-scheme: light; } /* user chose light */
:root.force-dark { color-scheme: dark; } /* user chose dark */
// JS - toggle is just a class swap
document.documentElement.classList.remove('force-light', 'force-dark');
document.documentElement.classList.add('force-dark');
// → Every light-dark() on the page now picks its second value
The recommended architecture
/* 1. Define semantic colour tokens on :root */
:root {
color-scheme: light dark;
--color-bg-primary: light-dark(white, #0f0f1a);
--color-bg-surface: light-dark(#f8f8fa, #1a1a2e);
--color-text-primary: light-dark(#1a1a2e, #f0f0f0);
--color-text-secondary: light-dark(#666, #aaa);
--color-border: light-dark(#e0e0e0, #333);
--color-accent: oklch(65% 0.2 250);
}
/* 2. Override with .light / .dark class for user toggle */
:root.light { color-scheme: light; }
:root.dark { color-scheme: dark; }
/* 3. Components ONLY reference tokens - never raw colours */
.card {
background: var(--color-bg-surface);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
The principle: Separate the three concerns: (1) token definition (what colours exist), (2) theme mode (which variant is active), (3) component usage (referencing tokens). Components should never contain theme logic - they just use tokens.
Browser support
oklch(): Chrome 111+, Safari 15.4+, Firefox 113+color(display-p3 ...): Chrome 111+, Safari 15+, Firefox 113+- Relative colour syntax (
from): Chrome 119+, Safari 16.4+, Firefox 128+ light-dark(): Chrome 123+, Safari 17.5+, Firefox 120+
Retrieval check
Question 1
What does the L in oklch represent, and how is it different from HSL's L?
Question 2
What does oklch(from var(--brand) l c calc(h + 180)) do?
Question 3
What happens if a wide-gamut oklch colour can't be displayed on an sRGB screen?
Question 4
What does light-dark(white, #1a1a2e) return?
Question 5
To desaturate a colour using relative colour syntax in oklch, which channel do you reduce?