Modern CSS colors: oklch(), color-mix() and more
Modern CSS has new color functions that fix long-standing problems with HEX and HSL. The two that matter most are oklch(), which describes a color by lightness, chroma and hue in a perceptually uniform way, and color-mix(), which blends two colors right in the browser. Together with relative color syntax and light-dark(), they let you generate scales, hover states and dark themes from a single variable.
These functions come from the CSS Color Level 4 and Level 5 specifications. This guide walks through the syntax of each one with real examples, explains when to reach for it and ends with a plan for migrating a HEX palette to OKLCH without breaking anything.
The new rgb() and hsl() syntax
The first change is small but shows up everywhere: values are now separated by spaces, and transparency goes after a slash. rgba() and hsla() still work, but you no longer need them.
/* legacy syntax */
color: rgba(255, 87, 51, 0.5);
/* modern syntax */
color: rgb(255 87 51);
color: rgb(255 87 51 / 50%);
color: hsl(11 100% 60% / 0.8);
All of these describe the same orange, #FF5733, with or without transparency. Alpha accepts a number (0.5) or a percentage (50%). Every newer function in this guide follows the same pattern.
hwb(), lab() and lch()
hwb()
hwb() describes a color by hue, amount of white and amount of black, a bit like mixing paint. It’s intuitive for lightening or darkening by hand:
color: hwb(11 20% 0%); /* = #FF5733 */
color: hwb(11 20% 30%); /* same hue, darker */
lab() and lch()
lab() and lch() use the CIELAB color space, designed to approximate human perception. In lab(), the first value is lightness and the other two are axes (green–red and blue–yellow). In lch(), those axes become chroma (intensity) and hue (an angle):
color: lab(61% 63.5 56); /* ≈ #FF5733 */
color: lch(61% 84.7 41.4); /* same color, polar coordinates */
CIELAB has a known quirk: when you change the lightness of some blues, the hue drifts toward purple. OKLab was created to fix that.
oklab() and oklch(): perceptually uniform color
OKLab, published by Björn Ottosson in 2020, keeps the idea behind Lab with more stable hues. oklch() is its polar form and the most practical format for everyday work:
color: oklch(0.68 0.21 33.7); /* ≈ #FF5733 */
color: oklch(68% 0.21 33.7 / 50%); /* with transparency */
color: oklab(0.68 0.175 0.116); /* same color in oklab */
- L (lightness): from 0 (black) to 1 or 100% (white).
- C (chroma): from 0 (gray) upward; in practice it rarely goes past 0.37.
- H (hue): an angle from 0 to 360.
Why it matters
In HSL, an L of 50% doesn’t mean the same brightness for every hue. Compare pure yellow and pure blue:
| Color | HSL | OKLCH |
|---|---|---|
#FFFF00 | hsl(60 100% 50%) | oklch(0.968 0.211 109.8) |
#0000FF | hsl(240 100% 50%) | oklch(0.452 0.313 264.1) |
According to HSL, both colors are equally light. To your eye, the yellow is far lighter, and OKLCH says so (0.968 vs 0.452). In practice, that means you can build scales where step 500 of every hue has similar contrast, and where changing only the hue keeps text readable.
:root {
--blue-100: oklch(0.95 0.03 263);
--blue-300: oklch(0.80 0.10 263);
--blue-500: oklch(0.55 0.21 263);
--blue-700: oklch(0.40 0.17 263);
--blue-900: oklch(0.28 0.10 263);
}
Notice that chroma drops at both ends of the scale. Very light or very dark colors can’t hold high chroma inside sRGB. When a value falls outside the display’s gamut, the browser maps it to the nearest color it can show.
color() and wide gamut (Display P3)
Most recent phones, Macs and many monitors can show more colors than sRGB, especially saturated greens and reds. The color() function lets you use those spaces:
.highlight { color: #FF0000; }
@media (color-gamut: p3) {
.highlight { color: color(display-p3 1 0 0); }
}
On a P3 screen, color(display-p3 1 0 0) is a visibly more intense red than red #FF0000. On sRGB screens, the browser brings the color back into the available gamut. oklch() can also reach beyond sRGB; you just use a higher chroma.
color-mix(): blending in the browser
color-mix() takes a color space and two colors with optional percentages:
:root { --brand: #2563EB; }
.button:hover {
background: color-mix(in oklch, var(--brand) 85%, black);
}
.soft-bg {
background: color-mix(in oklch, var(--brand) 12%, white);
}
.border {
border-color: color-mix(in srgb, var(--brand), transparent 60%);
}
The interpolation space changes the result. In srgb, mixing blue and yellow passes through a dull gray in the middle; in oklch or oklab, the transition is smoother and stays vivid. The same idea applies to gradients with linear-gradient(in oklch, …), which you can try in the gradient generator. The color mixer previews blends before you commit them to code.
Relative color syntax
Relative color syntax starts from an existing color and changes only the channels you want. The from keyword breaks the origin color into the channels of the chosen space (l, c and h in OKLCH; r, g and b in RGB):
:root { --brand: #2563EB; }
.lighter { color: oklch(from var(--brand) calc(l + 0.1) c h); }
.darker { color: oklch(from var(--brand) calc(l - 0.15) c h); }
.muted { color: oklch(from var(--brand) l 0.02 h); }
.glass { background: rgb(from var(--brand) r g b / 20%); }
.opposite { color: oklch(from var(--brand) l c calc(h + 180)); }
This way a whole design system can derive from one variable: change --brand and the light, dark, muted and translucent tones all update together.
light-dark(), currentColor and accent-color
light-dark()
light-dark() takes two colors and uses the first in the light scheme and the second in the dark one. It only works when color-scheme is set:
:root { color-scheme: light dark; }
body {
background: light-dark(#FFFFFF, #121212);
color: light-dark(#1F2937, #E5E7EB);
}
With color-scheme: light dark, the browser follows the operating system preference, and native form controls and scrollbars adapt as well.
currentColor
currentColor is the element’s current text color. It has been around for years, but it pairs nicely with the new functions: an SVG icon with fill: currentColor inherits the link color, and color-mix(in oklch, currentColor 30%, transparent) creates a border that follows the text.
accent-color
accent-color applies your brand color to native checkboxes, radio buttons, range sliders and progress bars without rebuilding the components:
input, progress { accent-color: #2563EB; }
Browser support and @supports fallbacks
oklch(), oklab(), color-mix() and relative color syntax are widely supported in modern browsers and are part of Baseline. light-dark() is newer, and older browsers still in use may not recognize it. Before you decide, check the up-to-date compatibility tables on MDN.
There are two simple ways to provide a fallback. The first relies on the cascade: the browser ignores a declaration it doesn’t understand and keeps the previous one.
.button {
background: #2563EB;
background: oklch(0.546 0.215 262.9);
}
The second uses @supports, which helps when a whole block depends on the feature:
:root { --surface: #FFFFFF; }
@supports (color: light-dark(white, black)) {
:root {
color-scheme: light dark;
--surface: light-dark(#FFFFFF, #121212);
}
}
How to migrate a HEX palette to OKLCH
Migration doesn’t have to be a single leap. A safe path:
- Inventory your colors. List every HEX in use and group them into tokens (
--brand,--text,--surface…). This step alone removes near-duplicate values. - Convert without changing anything. Use the color converter to get the OKLCH equivalent of each HEX. For example,
#2563EBbecomesoklch(0.546 0.215 262.9). Keep three decimals for L and C and one for H so there’s no visible shift. - Keep the HEX as a fallback in the same rule, as shown above, while your audience still includes older browsers.
- Only then, rework the scale. With values in OKLCH, align the lightness of each step (for example, every 500 shade near L 0.55) and ease off chroma at the ends.
- Check contrast. Perceptual uniformity doesn’t replace the WCAG calculation. Test text and background pairs in the contrast checker.
Watch out: over-rounded OKLCH values (like oklch(0.5 0.2 263)) can land slightly outside sRGB or produce a different color. Convert precisely and round only at the end.
Summary and next steps
- Use space-separated syntax with alpha after a slash in
rgb()andhsl(). - Prefer
oklch()for scales and themes: lightness stays consistent across hues. - Generate hovers, soft backgrounds and transparency with
color-mix(in oklch, …)or relative color. - Adopt
light-dark()withcolor-schemeand an@supportsfallback. - Migrate in stages: convert, keep HEX as a backup, and only then adjust the scale.
To practice, convert your colors in the converter, try blends in the mixer and build OKLCH gradients in the gradient generator. If the basic formats are still fuzzy, start with the color codes guide, and to put together a full palette, see how to choose a color palette for your website.