Understanding New CSS Color Spaces: oklch(), oklab(), and color-mix()

oklch() and oklab() offer perceptually uniform color spaces where equal numeric steps look equal, replacing hsl() for systematic color work with wide gamut support.

The CSS color system was rebuilt from the ground up, and the result is a set of tools that finally match how human vision actually works. The old workhorse, hsl(), was built on the sRGB color space, which is a narrow slice of the visible spectrum. Its lightness and saturation are mathematically simple but perceptually dishonest. Equal numerical steps in hsl() lightness do not produce equal perceived lightness changes. That makes it nearly impossible to build a consistent palette by hand. The new CSS color spaces oklch, oklab and wide gamut solve this. They are perceptually uniform: a 1% change in the lightness channel looks like the same visual step anywhere in the range. They can also express colors far outside sRGB, up to and including the full Display P3 and Rec.2020 gamuts. These functions shipped in all major engines by May 2023, which the MDN Baseline project records as widely available. The only reason to keep reaching for hsl() is habit.

Why Perceptual Uniformity Is the Whole Point of oklch() and oklab()

What Perceptual Uniformity Means

Perceptual uniformity is not a nice-to-have. It is the difference between guessing and engineering. In hsl(), the lightness channel is linear in intensity but not in perception. Two colors with the same lightness value can look like different shades of gray. Two colors a hue apart can appear closer or farther apart depending on their chroma. oklab() and oklch() use different math, derived from the CIELAB color space but corrected for the blue-light bend. The Euclidean distance between two colors in the space matches how different a viewer actually perceives them to be.

How This Changes Your Workflow

For the working front-end developer, this means that if you want a button to be 10% lighter, you write oklch(0.7 0.15 250) and you get it, every time, on any screen. The oklch() hue angle is also more intuitive than hsl()’s. It places blue at 250 degrees instead of 240, which is where the human eye actually responds to it. When you need to adjust a brand color for hover states or disabled states, the perceptual model means you are not fighting the color space itself.

The first practical replacement is building a palette. An hsl() palette with equal steps in the lightness channel looks banded and uneven. Some steps appear much darker or lighter than others. An oklch() palette is flat-out even. Here is a five-step scale from near-black to near-white, using a hue of 250 and a fixed chroma of 0.1. It renders as a consistent blue-gray on any display that supports the color space.

:root {
  /* Fallback for browsers without oklch() support: a plain sRGB hex */
  --step-1: #1a1a2e;
  --step-1: oklch(0.2 0.1 250);

  --step-2: #2a2a4e;
  --step-2: oklch(0.35 0.1 250);

  --step-3: #3f3f6e;
  --step-3: oklch(0.5 0.1 250);

  --step-4: #6a6a9e;
  --step-4: oklch(0.65 0.1 250);

  --step-5: #d0d0ff;
  --step-5: oklch(0.9 0.1 250);
}

The fallback declarations are essential. A browser that does not support oklch() will ignore the second declaration and use the hex value, which is a close sRGB approximation of the same color. Without those fallbacks, the palette disappears entirely in older browsers. That is a common failure when adopting new color syntaxes. The lightness range for oklch() is 0 to 1 (or 0% to 100%). The chroma range is 0 to approximately 0.5 for most achievable colors. The hue range is 0 to 360 degrees, matching the color wheel. This is the syntax that replaces an hsl() palette. Use it for any new design system that needs to be consistent across a team.

color-mix() CSS Function: Blending Without Opacity

The Stacking Context Problem

The color-mix() CSS function solves a specific, recurring problem. You want a color that blends two others, but you do not want to introduce a new stacking context. Opacity on a child element, such as a button overlay or a notification badge, creates a new stacking context. That messes up z-index ordering and fixed-position descendants. color-mix() achieves the same visual result by computing the blended color at build time, in the browser, without creating any new stacking context at all.

Syntax and a Worked Example

The syntax is color-mix(in colorspace, color1 percent1, color2 percent2). You can mix in any of the supported interpolation spaces: srgb, srgb-linear, lab, oklab, xyz, xyz-d50, xyz-d65, hsl, hwb, lch, oklch. For most uses, mixing in oklch gives the most perceptually even result. That is why the function accepts it as a first-class option.

Here is a complete example. Suppose you have a card with a white backdrop, and you want a subtle blue tint on a section of it. The old approach was to use a semi-transparent background on the section. That creates a stacking context that can trap the section’s own children. The new approach mixes the blue into the white directly, producing an opaque color indistinguishable from the transparency trick but with zero side effects on stacking.

.card {
  background: #ffffff;
  /* Fallback: a plain sRGB value */
  background: color-mix(in oklch, #0000ff 10%, #ffffff 90%);
}

This replaces the opacity-on-a-child pattern. The color-mix() function reached Baseline newly available in 2024. It is supported in current and recent versions of all major browsers. It is safe to ship for any project that does not need to support browsers older than about two years. The percentage values are relative, not absolute. Mix more or less of each color without recalculating the other. When you need a 50% mix, you can omit the percentages, but being explicit is clearer for maintainability. The function also works with any color syntax on either side, including oklch(), display-p3, and custom properties. That makes it the Swiss-army knife of systematic theming.

Display P3 CSS Color Gamut: Going Wider Than sRGB

The Display P3 CSS color gamut is the practical target for wide-gamut work. sRGB, the space that hex and hsl() live in, covers only about 35% of the visible spectrum. More importantly, it lacks the most saturated greens and cyans that modern displays can show. Display P3 was developed for cinema and is now standard on high-end monitors, phones, and the iPad. It covers about 45%. Its most vivid colors are noticeably more intense to the human eye. The color() function expresses these colors in CSS: color(display-p3 1 0 0) is the purest red that space allows. The grammar is color(colorspace c1 c2 c3 [/ alpha]). Channel values are 0 to 1 or 0% to 100%, except for hue which is an angle.

The Fallback Pattern for Wide Gamut

Here is the problem: if you write color(display-p3 1 0 0) directly and the browser does not support it, you get nothing. The correct pattern provides an sRGB fallback first, then the P3 value. Use @supports to guard the whole block for clarity. The @supports guard for color() is @supports (color: color(display-p3 1 0 0)) { … }. When a display does not support the wide gamut, the browser performs gamut mapping. By default, the CSS Color 4 specification uses chroma reduction in Oklch to bring the color into range without shifting its hue. A color that cannot be displayed is not clipped, which would produce a flat, washed-out patch. It is desaturated gradually, preserving the visual intent. The color() function has been widely available since May 2023. The support story is the same as oklch(): use it with a fallback for the few percent of users on older device-locked browsers.

CSS Color Space Interpolation: The Gradient Dead Zone

The CSS color space interpolation default is sRGB. That is the cause of the gray dead zone in many gradients. When you transition from one saturated color to another, for example from a vivid blue to a vivid yellow, the midpoint of an sRGB interpolation goes through the neutral gray axis. It produces a muddy brown-gray band. This happens because the intermediate values pass through equal parts of red, green, and blue, which is what gray is. In oklch interpolation, the transition goes around the hue wheel, holding chroma reasonably constant. The midpoint is a blue-yellow mix that stays vivid. To fix this, set interpolation-color-space or color-interpolation in a gradient. Or use the newer syntax that specifies the space directly in the color functions.

Before and After

Here is a runnable example that shows the difference. The first gradient uses the default sRGB interpolation and looks dead in the middle. The second uses oklch interpolation and stays colorful.

.bad-gradient {
  background: linear-gradient(to right, #0000ff, #ffff00);
  /* The midpoint is a muddy gray */
}

.good-gradient {
  background: linear-gradient(to right in oklch, #0000ff, #ffff00);
  /* The midpoint stays a vivid green */
}

The second declaration is the replacement for the first. The oklch() interpolation method is part of the CSS Color 4 specification and is supported in all engines that support oklch() itself. If you need to support very old browsers, write the gradient with the fallback as the first declaration and the new one second, like this:

.good-gradient-fallback {
  background: linear-gradient(to right, #0000ff, #ffff00); /* old browser */
  background: linear-gradient(to right in oklch, #0000ff, #ffff00); /* new */
}

This is the pattern to use everywhere: the fallback first, then the new feature. The @supports rule is unnecessary for gradients. An invalid gradient declaration is ignored, and the previous valid declaration is used. This is the same mechanism that makes the whole system work.

oklab() CSS Color Function: The Lightness-Only Tool

The oklab() CSS color function uses the same perceptual space as oklch() but with rectangular coordinates. It is the right tool when you want to adjust lightness without touching hue or saturation. In oklab(), the three channels are L, a, and b. L is the lightness from 0 to 1. a and b are the green-red and blue-yellow opponent axes. These axes can be negative, which is how you get colors outside the sRGB gamut. oklab() is the space to use when you are doing arithmetic on colors programmatically, such as in a design token system. Adding a fixed amount to L always produces the same perceived lightness change, regardless of the color’s hue or chroma. The oklch() syntax is the same space but with a polar coordinate system, which is more intuitive for humans to read and write. Both are widely available since May 2023, so you can use either confidently.

Generating a Hover State with oklab()

Here is a practical use of oklab() for generating a hover state. The old way was to use a filter: brightness(0.8). It works but also affects the entire element, including its text. The new way adjusts the L channel directly on the background color, leaving the text untouched.

.button {
  background: oklab(0.7 0.1 0.2);
  /* Fallback for older browsers */
  background: #8844aa;
}

.button:hover {
  background: oklab(from var(--base) calc(L - 0.1) a b);
}

The relative color syntax, oklab(from var(–base) l c h), is a separate feature that reached Baseline newly available in July 2024. It lets you take an existing color and modify one or more of its channels. That is the systematic way to build a theme. The syntax is oklch(from l c h), and you can use calc() inside to adjust values. This replaces the older workflow where you had to maintain a separate Sass variable for every shade of every color. The relative color syntax is safe to use with the fallback pattern. It is one of the most requested features in CSS, precisely because it closes the loop on programmatic color manipulation.

color() Function and Gamut Mapping: Handling the Edge Cases

The color() function is the escape hatch for any color space, not just Display P3. It accepts a space name, then three channel values, and an optional alpha. The full list of predefined spaces includes srgb, srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz, xyz-d50, and xyz-d65. Each has its own range of values. None of them are perceptually uniform on their own. Convert to oklch() or oklab() for adjustments and use color() for output.

How Gamut Mapping Works

The gamut mapping algorithm is the default behavior when a color falls outside the display’s range. It is defined by the CSS Color 4 specification as chroma reduction in Oklch. The browser takes the out-of-gamut color, reduces its chroma (saturation) while holding hue and lightness, until it lands inside the display gamut. The result is a smooth, perceptual approximation rather than a hard clip. For a page that must work on a narrow-gamut laptop, the fallback pattern with a hex value is the single most important line of code you write. The failure case is omitting the fallback. That leaves no color at all in unsupporting browsers. The @supports guard for color() is @supports (color: color(display-p3 1 0 0)). You can also declare the fallback before the new value, which is simpler and works for every property.

Gamut Coverage in Practice

The concept of gamut coverage is straightforward: wider is more vivid, but only if the display supports it. The percentage of the visible spectrum that sRGB covers is about 35%, Display P3 about 45%, and Rec.2020 about 75%. A monitor that claims 100% of sRGB is not showing you the same colors as one that claims 100% of Display P3. When you specify a color in Display P3, you are giving the browser permission to use those extra-vivid values. It must map them down if the screen cannot show them. This is not a bug. It is the intended behavior, and it is why you should always provide a fallback for a wider gamut.

Practical Fallbacks and @supports Guards

The One-Line Fallback Pattern

The accepted fallback pattern for any new color function is simple: write the old declaration first, then the new one. The browser ignores what it does not understand and uses the last valid one. For example, .foo { color: #ff0000; color: oklch(0.628 0.258 29.23); } is correct. The @supports rule is useful when you need to do more than set a single property, such as defining a whole set of custom properties that depend on the feature. The guard for oklch() is @supports (color: oklch(0 0 0)). For color() it is @supports (color: color(display-p3 1 0 0)). These guards are not perfect: they test the property:value pair, not the full syntax. They can return true even if the browser cannot parse a more complex expression. But they are the standard tool, and they are better than nothing.

Common Mistakes and How to Avoid Them

The most common mistake is omitting the fallback entirely. That failure appears only in the small slice of users on older device-locked browsers, such as iOS Safari on an unsupported device or Android WebView inside an app that does not update. It is not a negligible number; it is your entire grandmother’s town. The second most common mistake is using display-p3 values directly in oklch() or oklab() without conversion. That produces a completely wrong color because the channel meanings are different. Always convert via a color calculator or by using the color() function as the source.

There is one technique to avoid: using opacity to create a tinted background. That creates a stacking context and can trap fixed positioning. Use color-mix() instead, as shown above. If you must support browsers that do not have color-mix(), the fallback is to compute the mixed color by hand in a color tool and paste it as a hex value. That is a maintenance burden. The number of people who need that fallback is shrinking. The magic threshold to target is the last two years of browser releases, which are all Baseline newly available or better.

FAQ: Five Quick Answers on New Color Spaces

Why should I switch from hsl() to oklch()? Because hsl() is not perceptually uniform: equal steps in its lightness channel do not look equal to the eye. Your palette will always have random dark and light spots. oklch() fixes that, so the same step in L always looks the same.

Can I use oklch() in a gradient? Yes, and you should, for gradients between saturated colors. Use the interpolation-color-space or the in oklch syntax inside the gradient to avoid the gray dead zone. The fallback is to write the old sRGB gradient first, then the new one.

What is the difference between oklch() and oklab()? They are the same underlying color space. oklch() uses cylindrical coordinates (lightness, chroma, hue), which is easier for writing by hand. oklab() uses rectangular coordinates (lightness, a, b), which is better for arithmetic and for adjusting lightness alone.

Do I really need a fallback for every color? Yes, if you care about the users on older browsers, which can be significant for a global product. The pattern is one line: the fallback first, then the new value. It costs almost nothing and protects against a total color failure.

When can I drop the fallback and use only oklch()? You can drop it when your analytics show zero visits from browsers without support. For most sites, that is never. Or you accept that the page will be unreadable for that minority. Given the cost is one line, the sensible choice is to keep it.

Who Should Adopt These Tools Now

The Working Developer

The reader who will get the most from oklch(), oklab(), and color-mix() is the working front-end developer who writes CSS daily, builds design systems, or maintains a component library. If you are the one who has to explain to a designer why the hover state looks off, or why the gradient has a gray band, these tools are your answer.

Educators and Designers

The technical writer or educator will also benefit. The accurate, sourced statements about Baseline status and syntax are exactly what you need to avoid laundering guesses into facts. But if you are learning to code from zero, this is not your starting point. Go to web.dev/learn/css or the MDN CSS first-steps guide, then return here for the mechanics. A designer who wants to know why a layout works should consult Every Layout or Refactoring UI first, then return for the CSS mechanics.

Who Should Look Elsewhere

If you are debugging a React state bug or comparing CSS-in-JS libraries, you are on the wrong site. This is pure CSS, and JavaScript state is a different subject. The developer who is not yet convinced will find that the switch costs an afternoon of practice. The payoff is a reduction in visual bugs that you will never see with hsl().