Learn CSS · Lesson 11 of 11 · 9 min

Colour that behaves

Widely availablesince Nov 2025 Widely availablesince Nov 2025 Newly availablesince May 2024

A typical stylesheet holds dozens of colours that somebody picked by eye: the hover shade, the border, the disabled grey, and then all of them again for dark mode. You only need to choose one. The others are arithmetic, and CSS can now do the arithmetic, in a colour space where the answers look right.

Why the old numbers lied

In hsl(), “lightness 50%” is a position in a formula, not something an eye agrees with. Yellow at 50% glows and blue at 50% is nearly navy, so a palette built by stepping the L value comes out uneven, and text that passes contrast on one swatch fails on the next. oklch() has the same three handles, lightness, chroma and hue, and its lightness is modelled on perception. Equal numbers look equally light.

Six cards

1

Define colours where lightness is honest

Widely available
hsl, lightness 50%
oklch, lightness 0.7
--brand-500: oklch(0.55 0.22 275);
--brand-300: oklch(0.75 0.22 275);   /* same hue and chroma, lighter */
Six hues at one lightness value. The top row is hsl, the bottom row is oklch. Full reference and who ships it →

Goes on: your primitive colour tokens.

Read it as oklch(lightness chroma hue). Lightness runs from 0 to 1, chroma from 0 (grey) to roughly 0.37, hue in degrees. To make a tint, raise L and leave the other two alone. The palette generator builds a whole ramp this way.

Careful. High chroma at very high or very low lightness can name a colour no screen can show. The browser brings it back into range, and the result may be duller than you expected. Stay under about 0.2 chroma for anything near white or black.

2

States are a mix, not a new colour

Widely available
.btn:hover {
  background: color-mix(in oklch, var(--brand), black 15%);
}
The hover shade is computed from the brand colour. Change the brand and the hover follows. Full reference and who ships it →

Goes on: hover, active, disabled and border colours.

Mixing toward black or white in oklch keeps the hue steady. Mixing in sRGB, the default of older tools, drifts toward grey in the middle. Mixing with transparent gives a tint that works on any background. Compare the spaces side by side in the colour studio.

What it replaced: Sass darken() and lighten(), which ran once at build time and could not follow a theme.

3

A family from one colour

Newly available
--brand:       #4f46e5;
--brand-muted: oklch(from var(--brand) l calc(c * 0.35) h);
--brand-tint:  oklch(from var(--brand) l c h / 12%);
One brand colour, and three more taken from it by changing a single channel each. Full reference and who ships it →

Goes on: role tokens that should stay related to the brand.

oklch(from var(--brand) l c h) opens a colour up and hands you its channels as variables. Keep the hue and lower the chroma for a muted version, keep everything and add alpha for a tint, or rotate the hue by 180 for a complement. When the brand changes, the family changes with it.

Careful. Check the badge. Where it is not supported the whole declaration is dropped, so put a plain colour on the line before it.

4

Both themes in one declaration

Newly available

One declaration holds both values. The checkbox only flips color-scheme.

:root { color-scheme: light dark; }
.card { background: light-dark(#fff, #14161d);
        color:      light-dark(#14161d, #f2f3f7); }
The checkbox changes color-scheme and nothing else. Every colour follows. Full reference and who ships it →

Goes on: role tokens. color-scheme goes on :root.

color-scheme: light dark tells the browser the page supports both, which also fixes form controls and scrollbars. light-dark(a, b) then picks a value by the scheme in effect. Set color-scheme: dark on any element and everything inside it flips, so a dark footer on a light page needs no second set of tokens.

What it replaced: every colour declared twice, once inside prefers-color-scheme.

5

Text that picks its own colour

Newly available
#0a0a0f#ffd166#4f46e5#e8f5ee
.tag { background: var(--tag-colour);
       color: contrast-color(var(--tag-colour)); }
Each tag asks for black or white, whichever reads better on its background. Full reference and who ships it →

Goes on: text over a background a user or a CMS can change.

It returns black or white, never a brand colour, and it aims at readable, not at beautiful. That makes it right for tags, avatars and user-chosen label colours, and wrong for a headline over your hero. Until the badge says otherwise, set a colour by hand first and let this replace it.

6

Wider gamut, with the safe colour first

Widely available
.cta {
  background: #f0532d;                         /* everyone */
}
@media (color-gamut: p3) {
  .cta { background: color(display-p3 0.98 0.30 0.12); }
}

Goes on: one or two accents, not the whole palette.

Many current phones and laptops can show colours outside sRGB, more saturated oranges, greens and pinks in particular. The media query asks the screen, which matters more here than the browser does. An oklch() value with high chroma reaches the same colours and needs no query, because the browser fits it to whatever the screen can do.

Practice

  1. In the colour studio, enter your brand colour and copy the hover, muted and border values it derives. Replace three hand-picked colours in a project with them.
  2. Add color-scheme: light dark to a page and convert just the background and text colour to light-dark(). Switch your system theme and see how far two declarations get you.
  3. Write a five-step ramp by hand in oklch(), changing only lightness: 0.95, 0.85, 0.7, 0.55, 0.4. Compare it with the same steps in hsl().

Check yourself

A designer gives you one brand colour and asks for hover, a 10% tint for backgrounds, and a muted version. How many colours do you type?

One. Hover is color-mix() toward black (card two). The tint and the muted version are relative colours from the brand (card three).

Why does a palette stepped in hsl lightness look uneven, and the same steps in oklch do not?

HSL lightness is a calculation on RGB numbers and takes no account of how bright each hue looks. OKLCH lightness is built from measurements of perception, so equal steps look equal across hues.

When is contrast-color() the wrong tool?

When the text colour is a design decision. It only ever answers black or white. Use it where the background is not yours to choose.

The mental model to keep

Treat colour like layout: state the relationships and let the browser work out the values. This is darker than that, this is the same hue with less chroma, this pair swaps in the dark. A palette written as relationships can be rebranded by editing one line, and it cannot fall out of step with itself. This lesson is the second tier of the custom properties system, filled in.

Last reviewed 2026-09-19. Browser status on this page is read from the web-features dataset when the site is built, not typed.