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.
: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.
.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.
.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.
Try it yourself
- 1
Fix the yellow
In the first example, the
hsl 60swatch fails with white text. Predict which oklch lightness would make white text readable on a yellow, then tryoklch(0.45 0.12 90). Yellow can only stay bright-looking at high lightness, which is why yellow buttons usually get dark text. - 2
Mix in a different space
In the second example, change the JavaScript to mix
blueandyellowin srgb, thenin oklch. Predict which result is greyer. Add a swatch for each so you can see them. - 3
Rebrand in one line
In the third example, set
--brandtooklch(0.6 0.2 25). The normal, hover, soft and disabled buttons all update together. - 4
Find the passing grey
In the fourth example, try
#757575and#787878. Predict whether each passes 4.5:1 before running.
Code & diagrams
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
Console
Nothing logged yet.Expected console output
computed: oklch(0.6 0.15 90)Change --brand to any colour and all four swatches follow. Mixed colours are reported in the colour space they were mixed in.
Preview
Console
Nothing logged yet.Expected console output
red + blue in srgb: color(srgb 0.5 0 0.5)Change only --brand (try oklch(0.6 0.18 150) for green) and every derived colour follows.
Preview
Console
Nothing logged yet.The two greys look identical, but only the first reaches 4.5:1. That is why you measure instead of guessing.
Preview
Console
Nothing logged yet.Expected console output
#767676: 4.54
#777777: 4.48
black on white: 21.00Break 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.
Break #2
Light grey text on white
Set placeholder-like body text to color: #aaaaaa on a white background.
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 ofcolor-mix(): browsers that shipped it in 2023 require it.oklabis a good default for even-looking blends,srgbmatches what design tools usually do, andoklchkeeps 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(): inoklch(from X …),lis 0–1,cis the chroma number andhis 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
**
oklch(L C H)has three parts. L is perceived lightness**, from0(black) to1(white), also written0%–100%. C is chroma, how colourful it is, from0(grey) up to about0.37for 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 likeoklch(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, whichhsl()can't promise. (oklchis the polar form of the OKLab colour space, published in 2020.) - 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()andhsl()can only describe sRGB colours.oklch()(andcolor(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-vividoklch()is safe but looks a little less vivid on older screens. - 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 withtransparentmakes 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 yellowin srgbpasses through grey, whilein oklchstays colourful. - 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 intol,candh, 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 afteroklch()andcolor-mix(). - 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
#767676on white is 4.54:1 (passes);#777777is 4.48:1 (fails). - 6
Browser support and fallbacks.
oklch()andcolor-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
What do the three numbers in oklch() mean, and what does it fix compared with hsl()?
What is a colour gamut, and what happens when you use a colour the screen can't show?
Show two uses of color-mix() and explain why the colour space matters.
What are the WCAG contrast thresholds, and how is the ratio calculated?
Practice
Make three swatches .red, .green, .blue with the same oklch lightness 0.65 and chroma 0.15, and a fallback hex for each.
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.