Modern CSS Color: oklch, color-mix(), and the New Color Spaces
Modern CSS color introduces perceptually uniform spaces (oklch, oklab) that make programmatic color manipulation predictable, wide-gamut access via color(display-p3 …), and the color-mix() function plus relative color syntax for deriving palettes from a single base. oklch() uses three channels — lightness, chroma, hue — where equal numeric steps look equally different to the eye. All of these features ship in every evergreen browser as of 2026, defined in CSS Color Module Level 4.
CSS color has undergone its most significant evolution since the introduction of rgba() over a decade ago. For years, web developers were limited to the sRGB color space, expressing colors through hex codes, rgb(), and hsl(). While these formats served us well, they suffer from fundamental limitations: a restricted gamut that cannot reproduce the vibrant colors modern displays can show, and perceptual non-uniformity that makes programmatic color manipulation unpredictable. The new CSS color specifications — CSS Color Module Level 4 and the CSS Color Module Level 5 draft — address both problems head-on.
The modern CSS color ecosystem introduces multiple new color spaces — oklch, oklab, lab, lch, and display-p3 — along with powerful functions like color-mix() and relative color syntax that make palette generation, theming, and dark mode implementation dramatically easier. These are not experimental features hidden behind flags; they have shipped in all major browsers and are ready for production use today. For canonical reference material see MDN: oklch().
This guide takes you through the entire modern color landscape, from understanding why new color spaces matter, through practical palette-building techniques, to production-ready patterns for adaptive theming. Whether you are building a design system from scratch or upgrading an existing codebase, this knowledge will fundamentally change how you work with color in CSS.
Why New Color Spaces Matter
Two problems drive the need for new color spaces: gamut and perceptual uniformity.
Gamut refers to the range of colors a color space can represent. The sRGB color space, which underlies hex, rgb(), and hsl(), was defined in 1996 to match the capabilities of CRT monitors. Modern displays — including every iPhone since the iPhone 7, every MacBook since 2016, and most flagship Android devices — support the Display P3 color space, which can reproduce approximately 50% more colors than sRGB. When you specify a color in sRGB, you are voluntarily restricting yourself to the narrower gamut, leaving vivid greens, deep reds, and saturated cyans on the table.
Perceptual uniformity is about how evenly a color space distributes lightness and saturation. In HSL, changing the lightness value by 10% produces dramatically different visual results depending on the hue. A 10% lightness shift in yellow looks subtle, while the same shift in blue looks dramatic. This makes it nearly impossible to generate consistent tint/shade scales programmatically. Perceptually uniform color spaces like oklch solve this: a 10-unit change in lightness looks equally noticeable across all hues.
sRGB Limitations in Practice
Consider a common task: generating a set of blue shades for a button hover state. In HSL, you might decrease the lightness by 10%:
/* HSL approach: inconsistent perceived contrast */
.button { background: hsl(220, 80%, 50%); }
.button:hover { background: hsl(220, 80%, 40%); }
.button:active { background: hsl(220, 80%, 30%); }
/* The jump from 50% to 40% looks different
than the jump from 40% to 30% */
The perceptual difference between each step is uneven. Worse, if you try the same approach with a yellow button, the results look entirely different because HSL's lightness channel does not account for the fact that pure yellow is inherently lighter than pure blue to the human eye. You end up hand-tuning every color individually, which defeats the purpose of having a systematic scale.
oklch Explained: Lightness, Chroma, Hue
The oklch() function defines colors using three intuitive channels:
- L (Lightness): A value from 0% (black) to 100% (white). Unlike HSL's lightness, this is perceptually uniform — a 10% change looks equally significant regardless of hue.
- C (Chroma): The colorfulness or saturation, ranging from 0 (gray) upward. Unlike HSL's saturation percentage, chroma is unbounded — different hues have different maximum chroma values. Typical values range from 0 to about 0.4 for sRGB colors, and higher for wide-gamut colors.
- H (Hue): The hue angle in degrees, from 0 to 360. Red is around 25, yellow around 90, green around 145, cyan around 195, blue around 265, and purple around 310.
/* oklch syntax */
.brand-blue {
color: oklch(55% 0.2 260); /* L C H */
}
.brand-blue-light {
color: oklch(75% 0.15 260); /* Same hue, lighter */
}
.brand-blue-muted {
color: oklch(55% 0.08 260); /* Same lightness, less chroma */
}
.brand-blue-transparent {
color: oklch(55% 0.2 260 / 50%); /* With alpha */
}
The beauty of oklch is that adjusting one channel has a predictable, isolated effect. Increasing lightness makes the color lighter without shifting the hue. Reducing chroma desaturates toward gray without changing lightness. Rotating the hue changes the color family without affecting brightness. This orthogonality is what makes oklch ideal for design systems and programmatic color generation.
lab and lch vs oklch
CSS also supports lab() and lch(), which are based on the CIELAB color space standardized in 1976. These were the first perceptually uniform color spaces available in CSS, and they work well. However, they have a known issue: the blue-purple hue region is not perfectly uniform. Colors around hue 270-310 in LCH can exhibit unexpected hue shifts when you change the lightness.
The oklch() function is based on Oklab, developed by Bjorn Ottosson in 2020 to fix these hue-shift issues. It provides better perceptual uniformity across the entire hue range, particularly in the blue and purple regions. For this reason, oklch is generally the recommended choice for new projects. The CSS working group and most design tool authors have converged on oklch as the standard for perceptually uniform color manipulation.
The color() Function for Wide-Gamut Displays
To access colors outside the sRGB gamut, use the color() function with a specific color space identifier. The most common choice is display-p3, which matches the capabilities of modern wide-gamut screens:
/* A vivid red that goes beyond sRGB */
.vivid-red {
/* sRGB fallback */
background: rgb(255, 0, 0);
/* P3 red: more vivid than sRGB can express */
background: color(display-p3 1 0 0);
}
/* A deep green impossible in sRGB */
.deep-green {
background: color(display-p3 0 0.9 0.2);
}
The color() function accepts srgb, srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz, xyz-d50, and xyz-d65 as color space identifiers. For most web work, display-p3 is the practical choice because it matches real hardware. The other spaces are useful for color science applications or interoperability with specific imaging workflows.
color-mix() Syntax and Use Cases
The color-mix() function blends two colors in a specified color space. Its syntax is:
color-mix(in <color-space>, <color> <percentage>?, <color> <percentage>?)
The color space parameter determines how the interpolation happens. Mixing in oklch preserves perceptual uniformity; mixing in srgb can produce muddy mid-tones (especially when mixing complementary colors). Here are practical examples:
/* Mix blue and white in oklch to get a tint */
.light-blue {
background: color-mix(in oklch, #2563eb 70%, white);
}
/* Create a semi-transparent version of a color */
.overlay {
background: color-mix(in oklch, var(--brand) 40%, transparent);
}
/* Blend two brand colors */
.accent {
color: color-mix(in oklch, var(--primary) 60%, var(--secondary));
}
/* If percentages are omitted, defaults to 50% each */
.midpoint {
color: color-mix(in oklch, red, blue);
}
The percentages indicate how much of each color to use. color-mix(in oklch, blue 75%, white) means 75% blue and 25% white. If only one percentage is specified, the other is calculated as the remainder. If both are omitted, each color contributes 50%.
Building Tint/Shade Scales with color-mix()
One of the most practical applications of color-mix() is generating consistent tint and shade scales from a single base color. By mixing with white for tints and black (or a very dark color) for shades, you can create an entire palette in CSS without any build tools:
:root {
--blue-500: oklch(55% 0.22 260);
/* Tints: mix with white */
--blue-50: color-mix(in oklch, var(--blue-500) 5%, white);
--blue-100: color-mix(in oklch, var(--blue-500) 15%, white);
--blue-200: color-mix(in oklch, var(--blue-500) 30%, white);
--blue-300: color-mix(in oklch, var(--blue-500) 50%, white);
--blue-400: color-mix(in oklch, var(--blue-500) 75%, white);
/* Shades: mix with black */
--blue-600: color-mix(in oklch, var(--blue-500) 80%, black);
--blue-700: color-mix(in oklch, var(--blue-500) 60%, black);
--blue-800: color-mix(in oklch, var(--blue-500) 40%, black);
--blue-900: color-mix(in oklch, var(--blue-500) 25%, black);
--blue-950: color-mix(in oklch, var(--blue-500) 10%, black);
}
Because the mixing happens in oklch, each step has a perceptually uniform lightness difference. The result is a scale that looks evenly spaced to the human eye, unlike what you would get by mixing in sRGB or adjusting HSL lightness values.
Relative Color Syntax
Relative color syntax is one of the most powerful additions to CSS color. It lets you derive a new color from an existing one by modifying individual channels. The syntax uses the from keyword followed by the base color, then channel values that can reference the original channels by name:
/* Start from a base color and adjust channels */
.hover {
/* Take --brand, keep hue and chroma, reduce lightness by 10% */
background: oklch(from var(--brand) calc(l - 0.1) c h);
}
.muted {
/* Reduce chroma to 30% of original */
background: oklch(from var(--brand) l calc(c * 0.3) h);
}
.complement {
/* Rotate hue 180 degrees */
background: oklch(from var(--brand) l c calc(h + 180));
}
.transparent-version {
/* Add 50% transparency */
background: oklch(from var(--brand) l c h / 50%);
}
The channel names in oklch are l, c, and h. In HSL they would be h, s, l. In sRGB they are r, g, b. You can use calc() on any channel, and the alpha keyword references the original alpha value. This syntax works with all color functions: oklch(), oklab(), hsl(), rgb(), lab(), lch(), and color().
Automatic Dark Mode Palettes
Relative color syntax and color-mix() make dark mode implementation far simpler. Instead of maintaining two completely separate sets of color tokens, you can derive dark mode colors from your light mode palette by manipulating lightness and chroma:
:root {
--surface: oklch(98% 0.01 260);
--text: oklch(20% 0.02 260);
--primary: oklch(55% 0.22 260);
--primary-hover: oklch(from var(--primary) calc(l - 0.08) c h);
}
@media (prefers-color-scheme: dark) {
:root {
--surface: oklch(15% 0.02 260);
--text: oklch(90% 0.02 260);
--primary: oklch(70% 0.18 260);
--primary-hover: oklch(from var(--primary) calc(l + 0.08) c h);
}
}
Notice how the dark mode adjustments are intuitive: the surface becomes dark, text becomes light, and the primary color gets lighter with slightly reduced chroma (vivid colors on dark backgrounds can appear to glow). The hover state direction also inverts — lighter on hover in dark mode, darker on hover in light mode. All of this is expressed declaratively in CSS without any JavaScript.
Gamut Mapping and Fallbacks
When you specify a color in oklch with high chroma, it may fall outside the sRGB gamut. Browsers handle this through gamut mapping — they find the closest representable color in the target color space. The CSS specification defines a specific algorithm for this mapping that preserves the lightness and hue while reducing chroma until the color fits within the gamut.
For older browsers that do not support oklch or color-mix(), provide fallbacks using the cascade:
.button {
/* Fallback for older browsers */
background: #2563eb;
/* Modern browsers use this instead */
background: oklch(55% 0.22 260);
}
You can also use @supports for more complex fallback patterns. However, for simple color declarations, the cascade approach is cleaner and sufficient. Browsers that understand oklch will use the second declaration; those that do not will ignore it and use the hex fallback.
Browser Support
The oklch() and oklab() functions are supported in Chrome 111+, Edge 111+, Firefox 113+, and Safari 15.4+. The color-mix() function is supported in Chrome 111+, Edge 111+, Firefox 113+, and Safari 16.2+. Relative color syntax has broader support as of 2024, available in Chrome 119+, Edge 119+, Firefox 128+, and Safari 16.4+. The color() function with display-p3 is supported in Chrome 111+, Firefox 113+, and Safari 15+.
Given these support baselines, the modern color features are safe to use in production with simple cascade-based fallbacks for the small percentage of users on older browser versions.
Frequently Asked Questions
What is the oklch() color function in CSS?
oklch() defines a color in the perceptually uniform Oklab space using three channels: L (lightness 0–100%), C (chroma, unbounded but typically 0–0.4 for sRGB), and H (hue angle 0–360°). Equal numeric changes look equally significant to the human eye, making it ideal for programmatic palettes.
What is the difference between oklch and HSL?
HSL is mathematically convenient but perceptually uneven: a 10% lightness change in yellow looks subtle while the same change in blue looks dramatic. oklch is perceptually uniform across all hues, so adjusting lightness or chroma produces predictable, hue-independent results.
How does color-mix() work in CSS?
color-mix(in <color-space>, <color> <percentage>?, <color> <percentage>?) blends two colors in the chosen color space. Mixing in oklch preserves perceptual uniformity. Omit percentages and each color contributes 50%; supply only one and the other becomes the remainder.
Is oklch supported in all modern browsers?
Yes. oklch() and oklab(): Chrome 111+, Edge 111+, Firefox 113+, Safari 15.4+. color-mix(): Chrome 111+, Edge 111+, Firefox 113+, Safari 16.2+. Both reached Baseline Widely Available.
What is CSS relative color syntax?
Relative color syntax derives a new color by modifying individual channels of an existing one: oklch(from var(--brand) calc(l - 0.1) c h). It works inside oklch(), oklab(), hsl(), rgb(), lab(), lch(), and color().
How do I access wide-gamut colors like Display P3?
Use the color() function with a color-space identifier: color(display-p3 1 0 0) produces a vivid red beyond sRGB. Provide an sRGB hex declaration first as a fallback so older browsers ignore the wide-gamut value.
Further Reading
External references: MDN — oklch() · W3C — CSS Color Module Level 4 · web.dev — CSS color-mix().
- Color Mix Playground — interactively experiment with color-mix() across different color spaces and see generated CSS
- All CSS Guides — explore more in-depth CSS tutorials and references
- 10 Modern CSS Features You Should Use in Every Project — color-mix() is one of the ten essential features covered
- CSS View Transitions: From Basic to Advanced — combine modern colors with view transitions for dynamic theming effects