← Back to Index

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:

The colour wheel - what hue angles mean

The hue wheel - 12 positions around 360 degrees (OKLCH)

pink 30° red 60° orange 90° yellow 120° lime 150° green 180° cyan 210° teal 240° blue 270° violet 300° purple 330° magenta
/* 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

0 red
60 orange
120 green
180 cyan
240 blue
300 pink
/* 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

hsl red
hsl yellow
hsl green
hsl blue

OKLCH: all 65% lightness - genuinely equal perceived brightness

oklch red
oklch yellow
oklch green
oklch blue

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):

P3 red
P3 green
P3 blue

sRGB equivalent (clamped):

sRGB red
sRGB green
sRGB blue
/* 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.

Ad

Relative colour syntax - derive colours from other colours

One base colour → an entire tint/shade scale by modifying lightness

L: 95%
L: 85%
L: 75%
L: 65%
L: 50%
L: 35%
L: 20%
/* 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

50
100
200
300
400
500
600
700
800
900
: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

I use 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

Retrieval check

Question 1

What does the L in oklch represent, and how is it different from HSL's L?

Luminance - it's the same as HSL's lightness
Perceptual lightness - calibrated to human vision (unlike HSL's mathematical average)
Luminosity - measures screen brightness in nits
Level - a discrete step from 0 to 10

Question 2

What does oklch(from var(--brand) l c calc(h + 180)) do?

Inverts the colour (like a negative)
Creates the complementary colour (180 degree hue shift, same lightness and chroma)
Doubles the brightness
Rotates the colour 180 times

Question 3

What happens if a wide-gamut oklch colour can't be displayed on an sRGB screen?

The browser shows an error
The colour is replaced with black
The browser gamut-maps it to the closest displayable colour
The declaration is ignored entirely

Question 4

What does light-dark(white, #1a1a2e) return?

Always white - light comes first
A mix of white and dark
white in light mode, #1a1a2e in dark mode (based on active color-scheme)
Depends on the time of day

Question 5

To desaturate a colour using relative colour syntax in oklch, which channel do you reduce?

L (lightness)
C (chroma)
H (hue)
Alpha