
Modern CSS Color Functions: oklch(), color-mix(), and Relative Colors
Here's a familiar situation: the designer hands you a brand colour, and you need a hover state that's a bit darker, a disabled state that's washed out, a subtle background tint, and a border somewhere in between. Traditionally you'd open a colour picker, eyeball five hex values, paste them into variables, and hope nobody changes the brand colour next quarter.
Modern CSS can generate all of those from the brand colour itself. oklch() gives you a colour space where lightness actually behaves like lightness. color-mix() blends two colours in any colour space you choose. And relative colour syntax lets you take an existing colour, pull out its channels, and adjust them with maths. Together, they turn colour from a list of magic hex values into a small system you can reason about.
In this guide, I'll explain why the older colour formats fall short, how each new function works, and how to combine them into a practical, themeable palette.
Why hex, rgb(), and hsl() Fall Short
hsl() was meant to be the human-friendly colour format: hue, saturation, lightness. The trouble is that its lightness isn't perceptual. Compare these two:
.yellow {
background: hsl(60 100% 50%);
}
.blue {
background: hsl(240 100% 50%);
}
Both have a lightness of 50%, but the yellow looks dramatically brighter than the blue to the human eye. That means you can't create a consistent palette by fixing lightness and rotating hue, and you can't reliably predict whether white text will be readable on an HSL colour just from its L value.
There's a second limitation: rgb(), hsl(), and hex are all confined to the sRGB gamut. Most modern phones and laptops have wide-gamut displays that can show more vivid colours, which sRGB syntax simply can't describe.
Understanding oklch()
oklch() describes colours in the OKLCH colour space, a cylindrical form of OKLab, which was designed so that equal numeric changes look like roughly equal visual changes.
.brand {
color: oklch(62% 0.19 264);
}
It has three channels, plus optional alpha:
- L (lightness): from
0%(black) to100%(white). Unlike HSL, this tracks how light the colour actually looks. - C (chroma): how colourful it is.
0is gray. Practical values rarely go beyond about0.37, and many colours at high chroma are outside what screens can display. - H (hue): an angle from
0to360. Roughly: 30 is red-orange, 90 is yellow, 145 is green, 250 is blue, 330 is pink. - Alpha: after a slash, like
oklch(62% 0.19 264 / 0.5).
The same "fix L, rotate H" experiment now produces colours that look equally light:
.swatch-1 {
background: oklch(70% 0.15 30);
}
.swatch-2 {
background: oklch(70% 0.15 145);
}
.swatch-3 {
background: oklch(70% 0.15 264);
}
Building a Tonal Scale
Because lightness is perceptual, you can build a scale from lightest to darkest by stepping L while keeping hue fixed and tapering chroma at the extremes:
:root {
--indigo-50: oklch(97% 0.02 270);
--indigo-100: oklch(93% 0.04 270);
--indigo-300: oklch(80% 0.1 270);
--indigo-500: oklch(62% 0.19 270);
--indigo-700: oklch(47% 0.18 270);
--indigo-900: oklch(30% 0.1 270);
}
Copy the scale, change the hue to 150, and you get a green scale with the same perceived weights. That's something HSL can't give you.
Wide Gamut and Out-of-Gamut Colours
OKLCH can describe colours beyond sRGB, including those available in the Display P3 gamut. If you ask for a colour a screen can't show, the browser gamut maps it to the closest displayable colour. You can target wide-gamut screens deliberately:
.cta {
background: oklch(65% 0.2 25);
}
@media (color-gamut: p3) {
.cta {
background: oklch(65% 0.26 25);
}
}
On a P3 display, the button gets a more vivid red. Everywhere else it stays within sRGB.
Mixing Colours with color-mix()
color-mix() blends two colours in a specified colour space and returns the result.
.button:hover {
background: color-mix(in oklch, var(--brand) 85%, black);
}
The syntax is:
color-mix(in <colorspace>, <color1> <percentage>?, <color2> <percentage>?)
- The colour space is required. Good choices are
oklch,oklab, andsrgb. - Percentages are optional. If you give one, the other is the remainder. If you give neither, it's 50/50.
- If both percentages add up to less than 100%, the result becomes partly transparent.
Choosing a Colour Space
The colour space affects the result, especially for mixes between very different hues:
.mix-srgb {
background: color-mix(in srgb, #3b82f6, #facc15);
}
.mix-oklab {
background: color-mix(in oklab, #3b82f6, #facc15);
}
.mix-oklch {
background: color-mix(in oklch, #3b82f6, #facc15);
}
Mixing blue and yellow in srgb gives a dull gray-green. oklab gives a cleaner, brighter midpoint. oklch interpolates around the hue wheel, producing a vivid teal-green. For tints and shades of a single colour (mixing with white or black), oklab is usually the most natural. For blends between hues where you want vibrancy, oklch often looks best.
Practical Uses
Tints for backgrounds, shades for hover states, and translucent overlays all come from one variable:
:root {
--brand: oklch(62% 0.19 264);
}
.alert {
background: color-mix(in oklab, var(--brand) 12%, white);
border: 1px solid color-mix(in oklab, var(--brand) 40%, white);
color: color-mix(in oklab, var(--brand) 70%, black);
}
.overlay {
background: color-mix(in srgb, var(--brand) 30%, transparent);
}
Mixing with transparent is a neat way to add opacity to a colour stored in a variable, without needing its separate channels.
Relative Colour Syntax
color-mix() is great for blending, but sometimes you want surgical control: "the same colour, but 10% darker" or "the same hue, but grayer". Relative colour syntax lets you start from an existing colour and redefine any of its channels.
.button {
background: var(--brand);
}
.button:hover {
background: oklch(from var(--brand) calc(l - 0.08) c h);
}
The from keyword takes an origin colour and converts it into the target colour space. Inside the function, the channel names become variables:
- In
oklch(from ...), you getl,c,h, andalpha. - In
rgb(from ...), you getr,g,b, andalpha. - In
hsl(from ...), you geth,s,l, andalpha.
You can keep a channel as-is by writing its name, replace it with a fixed value, or compute it with calc().
One detail catches people out: in oklch(from ...), lightness is a number from 0 to 1, not a percentage. So calc(l - 0.08) darkens by 8 percentage points. The origin colour can be in any format, even a hex value, because it's converted first.
Useful Recipes
:root {
--brand: #4f46e5;
}
/* Darker for hover and pressed */
.btn:hover {
background: oklch(from var(--brand) calc(l - 0.07) c h);
}
.btn:active {
background: oklch(from var(--brand) calc(l - 0.14) c h);
}
/* Semi-transparent version */
.focus-ring {
outline: 3px solid oklch(from var(--brand) l c h / 0.4);
}
/* Muted, low-chroma version for disabled states */
.btn:disabled {
background: oklch(from var(--brand) 75% calc(c * 0.3) h);
}
/* Complementary accent: rotate the hue by 180 degrees */
.badge {
background: oklch(from var(--brand) l c calc(h + 180));
}
Each recipe describes intent rather than a hard-coded result. If the brand colour changes, every derived colour updates with it.
Putting It All Together: A One-Variable Theme
Here's a small component theme where every colour is derived from a single --hue value. Change the hue, and the whole component recolours consistently.
<div class="panel" style="--hue: 264">
<h3 class="panel__title">Deploy preview ready</h3>
<p class="panel__text">Your branch built successfully in 42 seconds.</p>
<a class="panel__button" href="/deploys/latest">Open preview</a>
</div>
<div class="panel" style="--hue: 150">
<h3 class="panel__title">All checks passed</h3>
<p class="panel__text">Twelve tests ran with no failures.</p>
<a class="panel__button" href="/checks">View report</a>
</div>
.panel {
--base: oklch(60% 0.17 var(--hue, 264));
padding: 1.25rem;
border-radius: 0.75rem;
background: oklch(from var(--base) 97% calc(c * 0.15) h);
border: 1px solid oklch(from var(--base) 88% calc(c * 0.4) h);
}
.panel__title {
margin: 0 0 0.25rem;
color: oklch(from var(--base) 30% c h);
}
.panel__text {
margin: 0 0 1rem;
color: oklch(from var(--base) 42% calc(c * 0.4) h);
}
.panel__button {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: var(--base);
color: white;
font-weight: 600;
text-decoration: none;
}
.panel__button:hover {
background: color-mix(in oklab, var(--base) 85%, black);
}
Because every colour uses a fixed perceptual lightness, the blue and green panels have the same visual weight, and the title text has similar contrast in both. That's the payoff of OKLCH.
Accessibility Considerations
Perceptual lightness helps with contrast, but it's not a replacement for checking it.
- OKLCH lightness correlates with contrast but doesn't guarantee it. As a rule of thumb, text around 40% L or darker on backgrounds around 95% L or lighter tends to pass WCAG AA, but always verify with a contrast checker.
- High chroma can hurt readability. Very saturated text on a saturated background can vibrate even when the ratio passes. Keep text chroma lower.
- Don't rely on hue alone. Error and success states that differ only by red and green are hard for colour-blind users to distinguish. Pair colours with icons or text.
Browser Support and Fallbacks
oklch() and color-mix() are supported in all current major browsers and have been Baseline since 2023. Relative colour syntax arrived later, landing in Chromium-based browsers first and in Firefox and Safari during 2024, so it's also available in all evergreen browsers today, although a few edge cases around mixed colour spaces were fixed after the initial releases.
If you need to support older browsers, declare a static fallback first. Browsers that don't understand the second declaration will ignore it:
.btn:hover {
background: #4338ca;
background: oklch(from var(--brand) calc(l - 0.07) c h);
}
For values stored in custom properties, the cascade fallback doesn't help, because an unsupported function inside a variable only fails when it's used. Use a feature query instead:
.btn:hover {
background: #4338ca;
}
@supports (color: oklch(from red l c h)) {
.btn:hover {
background: oklch(from var(--brand) calc(l - 0.07) c h);
}
}
Common Pitfalls
- Using percentages for lightness inside
calc()in relative syntax. Writecalc(l - 0.1), notcalc(l - 10%), sincelresolves to a number. - Forgetting the colour space in
color-mix(). The spec now allows it to be omitted, but older implementations require it, so always writein oklabor similar. - Assuming hue angles match HSL. OKLCH hues are not identical to HSL hues. Red sits around 25 to 30 in OKLCH rather than at 0. Pick hues with a colour picker that supports OKLCH, which modern browser DevTools do.
- Pushing chroma too high. Very high chroma colours get gamut mapped on sRGB screens, so two different values might look identical. Stay moderate unless you're deliberately targeting P3.
Conclusion
Modern CSS colour functions give you a way to express how colours relate to each other, not just what they are. oklch() makes lightness predictable and opens up wide-gamut colour. color-mix() blends colours in the space of your choosing, which is perfect for tints, shades, and transparency. Relative colour syntax lets you derive precise variations from a single source colour.
Start small: convert your brand colour to OKLCH, derive its hover and focus states with relative syntax, and generate backgrounds with color-mix(). Once you see a whole component recolour correctly from one variable, you won't want to go back to a spreadsheet of hex codes.


