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
Define colours where lightness is honest
Widely available--brand-500: oklch(0.55 0.22 275);
--brand-300: oklch(0.75 0.22 275); /* same hue and chroma, lighter */ Your browser does not support this yet, so the demo above shows the fallback. For older browsers declare a hex first and the oklch() value on the next line: an engine that cannot parse the second keeps the first.
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.
States are a mix, not a new colour
Widely available.btn:hover {
background: color-mix(in oklch, var(--brand), black 15%);
} Your browser does not support this yet, so the demo above shows the fallback. A browser without it ignores the hover declaration and the button keeps its resting colour, so the fallback is already written.
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.
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%); Your browser does not support this yet, so the demo above shows the fallback. Put a hex on the line above each derived colour. In a browser without it all four swatches above are the same indigo, which is the fallback doing its job.
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.
Both themes in one declaration
Newly availableOne 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); } Your browser does not support this yet, so the demo above shows the fallback. Declare the light value on its own line first. A browser without light-dark() keeps it, and gets a light theme only, which is what it had before.
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.
Text that picks its own colour
Newly available.tag { background: var(--tag-colour);
color: contrast-color(var(--tag-colour)); } Your browser does not support this yet, so the demo above shows the fallback. Set a readable text colour by hand first and let contrast-color() replace it inside @supports, exactly as the demo does. Never rely on it alone for a colour the user can change.
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.
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
- 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.
- Add
color-scheme: light darkto a page and convert just the background and text colour tolight-dark(). Switch your system theme and see how far two declarations get you. - 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 inhsl().
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.