Command Palette

Search for a command to run...

Hectal
PHASE 3Beginner Baseline 2023+ ~23 min· topic 5 of 7

Topic 3.5

Modern Colour

In one line

oklch() describes colour by how light it looks, how colourful it is and its hue, reaching the vivid colours of modern screens; color-mix() blends two colours, relative colour syntax derives new colours from an existing one, and contrast ratios tell you whether text is readable.

Think of it like this

Two lamps both labelled "50% brightness", one yellow and one blue. Your eyes say the yellow one looks far brighter. hsl() is like those labels: its lightness is a number, not what you see. **oklch()** is like a light meter built to match human eyes: equal lightness numbers *look* equally light.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

oklch()
A colour written as perceived Lightness, Chroma (colourfulness) and Hue, designed to match how eyes see.
Chroma
How colourful a colour is: 0 is grey, higher numbers are more vivid.
Gamut
The full range of colours a screen or colour space can show.
Display P3
A wider gamut than sRGB, shown by most modern phones and laptops, with more vivid reds and greens.
Gamut mapping
When a screen can't show a colour, the browser picks the closest one it can show.
color-mix()
A function that blends two colours in a chosen colour space and proportion.
Relative colour syntax
Writing a new colour as "this colour, but with these parts changed", using from.
Contrast ratio
A number from 1 to 21 that says how far apart two colours are in brightness. Text needs 4.5 or more.
Relative luminance
How much light a colour gives off, from 0 (black) to 1 (white), used to compute contrast.

Step by step

01Read an oklch colour

oklch(0.62 0.19 255): lightness 0.62 (a bit lighter than the middle), chroma 0.19 (quite vivid), hue 255 (blue). To make a darker blue, lower only L. To make a greyer blue, lower only C. To make it purple, move H towards 300.

brand.csswhole filecss
:root {
  --blue-300: oklch(0.80 0.10 255);
  --blue-500: oklch(0.62 0.19 255);
  --blue-700: oklch(0.45 0.17 255);
}

02Equal lightness that really looks equal

In hsl(), hsl(60 100% 50%) (yellow) and hsl(240 100% 50%) (blue) have the same lightness number, but the yellow looks far brighter, so white text works on the blue and fails on the yellow. In oklch(), colours with the same L look equally light, so the same text colour works on all of them.

03Mix tints and see-through versions

color-mix() saves you from hand-picking every tint. Mixing with white lightens, with black darkens, with transparent adds alpha.

mix.csswhole filecss
.tint   { background: color-mix(in oklch, var(--brand) 15%, white); }
.shade  { background: color-mix(in oklch, var(--brand) 70%, black); }
.glass  { background: color-mix(in srgb, var(--brand), transparent 85%); }

04Derive colours with relative syntax

With from, the base colour is split into named channels you can reuse or change with calc() (Topic 3.7). In oklch(from X l c h), the channels are l, c and h; in rgb(from X r g b), they are r, g and b, and alpha is available in both.

derive.csswhole filecss
.btn { background: var(--brand); }
.btn:hover { background: oklch(from var(--brand) calc(l - 0.08) c h); }
.btn:disabled { background: oklch(from var(--brand) l 0 h); }  /* grey, same lightness */

05Check the contrast

Each colour's relative luminance L is worked out from its red, green and blue values. The ratio is (lighter L + 0.05) / (darker L + 0.05). Aim for 4.5:1 for body text and 3:1 for large text and control borders.

Check the contrastdiagram
Rendering diagram…

Try it yourself

  1. 1

    Fix the yellow

    In the first example, the hsl 60 swatch fails with white text. Predict which oklch lightness would make white text readable on a yellow, then try oklch(0.45 0.12 90). Yellow can only stay bright-looking at high lightness, which is why yellow buttons usually get dark text.

  2. 2

    Mix in a different space

    In the second example, change the JavaScript to mix blue and yellow in srgb, then in oklch. Predict which result is greyer. Add a swatch for each so you can see them.

  3. 3

    Rebrand in one line

    In the third example, set --brand to oklch(0.6 0.2 25). The normal, hover, soft and disabled buttons all update together.

  4. 4

    Find the passing grey

    In the fourth example, try #757575 and #787878. Predict whether each passes 4.5:1 before running.

Code & diagrams

Same lightness in hsl versus oklch New tab

The top row has the same hsl lightness but looks uneven; white text vanishes on the yellow. The oklch row looks evenly light. oklch colours keep their own format when computed.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

computed: oklch(0.6 0.15 90)
Tints, shades and glass with color-mix() New tab

Change --brand to any colour and all four swatches follow. Mixed colours are reported in the colour space they were mixed in.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

red + blue in srgb: color(srgb 0.5 0 0.5)
One brand colour, a whole button set (relative colours) Baseline 2024+ New tab

Change only --brand (try oklch(0.6 0.18 150) for green) and every derived colour follows.

Preview

Press Run to see the page.

Console

Nothing logged yet.
Compute a WCAG contrast ratio New tab

The two greys look identical, but only the first reaches 4.5:1. That is why you measure instead of guessing.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

#767676: 4.54
#777777: 4.48
black on white: 21.00

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

A modern colour with no fallback

Write only background: oklch(0.62 0.19 255); and open the page in a browser from before 2023.

terminal
$ Open the page in an old browser
── what you'll see ──
The background is missing (transparent); the declaration was dropped as invalid.

Break #2

Light grey text on white

Set placeholder-like body text to color: #aaaaaa on a white background.

terminal
$ DevTools → colour picker → Contrast ratio
── what you'll see ──
Contrast ratio 2.32 with a red warning: fails AA for normal text (needs 4.5).

Myth vs fact

Myth

hsl() lightness 50% always looks medium-bright.

Fact

hsl lightness is a maths value, not what eyes see. Yellow at 50% looks much brighter than blue at 50%. oklch() lightness is designed to match perception.

Myth

An oklch() colour outside sRGB breaks on old screens.

Fact

The browser gamut-maps it to the nearest colour the screen can show. It looks slightly less vivid; nothing breaks.

Myth

If text looks readable to me, it passes contrast.

Fact

Perception varies with eyesight, screen and lighting. Measure: WCAG asks for 4.5:1 for normal text and 3:1 for large text and control borders.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Always write the in <space> part of color-mix(): browsers that shipped it in 2023 require it. oklab is a good default for even-looking blends, srgb matches what design tools usually do, and oklch keeps colours vivid between distant hues.

  • ▸

    Hue interpolation in polar spaces (oklch, hsl) takes the shorter way round the colour wheel by default; color-mix(in oklch longer hue, red, blue) goes the long way. The same keywords work in gradients: linear-gradient(in oklch, red, blue).

  • ▸

    Relative colour channels are plain numbers inside calc(): in oklch(from X …), l is 0–1, c is the chroma number and h is degrees, regardless of how X was written. Mixing % into those calculations is a common error that makes the whole value invalid.

  • ▸

    WCAG 2's contrast formula is known to over-rate some dark-on-dark pairs and under-rate light text on mid colours. APCA, a newer perceptual contrast method proposed for a future WCAG 3, addresses this, but WCAG 2's 4.5:1 is still the legal and audit standard in 2026.

Remember this

  1. 1

    **oklch(L C H) has three parts. L is perceived lightness**, from 0 (black) to 1 (white), also written 0%–100%. C is chroma, how colourful it is, from 0 (grey) up to about 0.37 for the most vivid colours. H is the hue angle, 0–360 (roughly: 30 red, 90 yellow, 145 green, 250 blue, 330 pink). Because L matches what eyes see, a palette like oklch(0.6 0.15 30), oklch(0.6 0.15 145), oklch(0.6 0.15 250) has red, green and blue that look equally light, which hsl() can't promise. (oklch is the polar form of the OKLab colour space, published in 2020.)

  2. 2

    Modern phones and laptops show more colours than sRGB, in a wider gamut (the range of colours a screen can show) called Display P3. Hex, rgb() and hsl() can only describe sRGB colours. oklch() (and color(display-p3 r g b)) can describe the extra vivid ones. If a screen can't show a colour, the browser gamut-maps it to the closest colour it can show, so a too-vivid oklch() is safe but looks a little less vivid on older screens.

  3. 3

    **color-mix(in <space>, colourA <percent>, colourB)** blends two colours, like mixing paint. color-mix(in srgb, #2563eb 20%, white) is 20% brand blue and 80% white: a pale tint. Mixing with transparent makes a see-through version: color-mix(in srgb, #2563eb, transparent 80%) is the blue at 20% alpha. The colour space you mix in changes the middle colours: mixing blue and yellow in srgb passes through grey, while in oklch stays colourful.

  4. 4

    Relative colour syntax builds a new colour from an existing one: oklch(from var(--brand) calc(l - 0.1) c h) takes the brand colour, splits it into l, c and h, makes it 0.1 darker and keeps the rest. rgb(from var(--brand) r g b / 50%) is the brand at half alpha. One base colour can generate a whole set of hover, border and tint colours without writing each by hand. It arrived in all major browsers in 2024, a year after oklch() and color-mix().

  5. 5

    Contrast is how different text is from its background in brightness. The accessibility standard WCAG 2 measures it as a contrast ratio from 1:1 (same colour) to 21:1 (black on white). Normal text needs at least 4.5:1; large text (24px, or about 18.7px bold) and important parts of controls like input borders and icons need 3:1. The ratio comes from each colour's relative luminance: how much light it gives off, after correcting for the screen's gamma. Grey #767676 on white is 4.54:1 (passes); #777777 is 4.48:1 (fails).

  6. 6

    Browser support and fallbacks. oklch() and color-mix() work in all major browsers since 2023, and relative colours since 2024. For older browsers, write a hex fallback first and the modern value second; old browsers drop the line they don't understand (Topic 0.3). DevTools' colour picker shows the contrast ratio and draws the sRGB gamut line, so you can check both by eye.

Explain it without notes

01

What do the three numbers in oklch() mean, and what does it fix compared with hsl()?

02

What is a colour gamut, and what happens when you use a colour the screen can't show?

03

Show two uses of color-mix() and explain why the colour space matters.

04

What are the WCAG contrast thresholds, and how is the ratio calculated?

Practice

01

Make three swatches .red, .green, .blue with the same oklch lightness 0.65 and chroma 0.15, and a fallback hex for each.

02

Given --brand: #0f766e, write a .callout whose background is a 12% tint of the brand mixed with white and whose border is the brand colour 0.1 darker in oklch.

Trade-offs

  • ↔

    oklch() gives predictable palettes and vivid P3 colours, but its numbers are less familiar than hex and need a fallback for very old browsers.

  • ↔

    Deriving colours with color-mix() and relative syntax keeps a theme in sync from one value, but makes the exact final colour harder to see in the source; check contrast for each derived pair.

Done when you can

  • I can read and adjust an oklch() colour.

  • I can explain gamut and what happens to out-of-gamut colours.

  • I can make tints, shades and transparent versions with color-mix().

  • I can derive hover and disabled colours with relative colour syntax.

  • I can check text contrast against the 4.5:1 and 3:1 rules.