Perceptual Uniformity, P3 Gamut and Better Gradients
oklch() is the most modern color space in CSS Color Level 4 and solves fundamental problems of hsl() and rgb(): the lightness axis is perceptually uniform, gradients no longer pass through desaturated gray tones, and access to the P3 display gamut enables more vivid colors on modern screens, directly in the browser, with no image editing software required.
Table of Contents
- 1. Why hsl() and rgb() reach their limits
- 2. oklch() basics: L, C and H explained
- 3. Perceptual uniformity: what it actually means
- 4. P3 gamut: more vivid colors on modern displays
- 5. Gradients with oklch(): no more gray midpoints
- 6. Design tokens with oklch() and custom properties
- 7. The color-gamut media query: P3 fallbacks done right
- 8. Migrating from hsl() and hex to oklch()
- 9. oklch() vs. hsl() head to head
- 10. Summary
- 11. FAQ
1. Why hsl() and rgb() reach their limits
The CSS color functions hsl() and rgb() have been the web standard for decades, and they have fundamental weaknesses that only become obvious once you compare them with modern color spaces such as the CSS oklch() color space. The biggest problem with hsl(): its lightness (L) is not perceptually uniform. Two colors with an identical L value in hsl() appear differently bright to the human eye, because the human visual system perceives colors of different wavelengths as differently bright. A blue purple hue at hsl(260, 100%, 50%) looks noticeably darker than a yellow green hue at hsl(90, 100%, 50%), even though both share the same lightness value.
The second problem: the sRGB color space that hsl() and rgb() operate in only covers part of the colors that modern displays can actually show. Displays with a P3 gamut (every iPhone since 2016, every newer MacBook, many Android flagship phones) can render more saturated, more vivid colors than sRGB allows. With hsl() and rgb(), those colors are simply unreachable. The CSS oklch() color space solves both problems at once: perceptually uniform axes and access to colors outside the sRGB gamut.
2. oklch() basics: L, C and H explained
The CSS oklch() function takes three parameters: L (lightness), C (chroma, saturation) and H (hue). Lightness L ranges from 0 (black) to 1 (white), with 0.5 as medium gray. The special part: this lightness axis is calibrated to be perceptually uniform. Blue at oklch(0.5 0.2 260) and yellow at oklch(0.5 0.15 90) genuinely appear equally bright to the human eye, which is not the case in hsl().
The chroma value C in the CSS oklch() color space is unbounded, theoretically any positive number can be specified. In practice, most sRGB colors fall in the range 0 to 0.37. P3 colors can reach roughly 0.37 to 0.45. Values above that get clipped by the browser to the displayable gamut of the screen. In practice this means: you can define a color as oklch(0.7 0.5 290) and the browser will render the most saturated version the display supports, more vivid on a P3 display than on an sRGB display. The hue H follows the same 0 to 360 degree principle as hsl(), but the distribution of colors around the circle is more perceptually even.
/* CSS oklch() color function, L C H syntax */
:root {
/* oklch(lightness chroma hue) */
--color-primary: oklch(0.55 0.22 290); /* vivid purple */
--color-primary-light: oklch(0.80 0.14 290); /* light purple */
--color-primary-dark: oklch(0.35 0.20 290); /* dark purple */
/* Perceptually uniform steps, each step feels equally different */
--blue-100: oklch(0.95 0.05 260);
--blue-300: oklch(0.80 0.10 260);
--blue-500: oklch(0.60 0.18 260);
--blue-700: oklch(0.40 0.15 260);
--blue-900: oklch(0.20 0.08 260);
/* Alpha channel, oklch with alpha */
--overlay: oklch(0.10 0.05 290 / 0.6);
/* Wide-gamut P3 color (exceeds sRGB) */
--accent-vivid: oklch(0.65 0.35 150); /* vivid green, P3-only */
}
.button-primary {
background: var(--color-primary);
color: oklch(0.98 0.01 290);
}
.button-primary:hover {
background: var(--color-primary-dark);
}
3. Perceptual uniformity: what it actually means
Perceptual uniformity is the central property of the CSS oklch() color space. It means that equal numeric distances on the lightness axis correspond to equally large perceived differences in the eye of the viewer. This is a mathematically precise calibration of the color space to the human visual system, and it makes a noticeable difference in every practical use of color. When a design system defines a color palette, say blue 100, blue 200, up to blue 900, the lightness steps are supposed to feel evenly spaced. With hsl() this systematically fails for certain hues, with CSS oklch() it works consistently.
A concrete example of what perceptual uniformity changes in practice: contrast calculations for accessibility. To guarantee WCAG compliant color contrast, the perceived lightness of two colors has to be measured. Calculations based on the CSS oklch() color space are more precise than calculations based on sRGB, because the lightness axis is already perceptually corrected. Design tokens built on CSS oklch() produce more consistent contrast ratios across different hues, a clear win for accessible design.
4. P3 gamut: more vivid colors on modern displays
The P3 color space (Display P3) covers roughly 25 percent more colors than sRGB. Above all this means more saturated greens, brighter oranges and more intense reds that sRGB displays simply cannot render. On an iPhone, a newer MacBook or a modern Android flagship with a P3 display, these colors look noticeably more vivid than the same colors rendered in sRGB. With the CSS oklch() color space, these P3 colors can be defined directly in CSS by pushing the chroma value beyond the sRGB range.
The browser handles this correctly on its own: on a P3 display the more vivid colors are shown. On an sRGB display the colors are clipped down to the displayable sRGB gamut, and that clipping happens as close to the original hue as possible, so the intent of the color survives. In practice this means it is safe to define P3 colors in CSS oklch() even if not every visitor has a P3 display. sRGB users see the closest achievable sRGB match, P3 users see the full brightness. For deliberate control over the fallback, the color-gamut media query is the recommended tool.
/* Wide-gamut colors with oklch, P3 gamut access */
/* sRGB-safe oklch color (C < 0.37 for most hues) */
.badge-safe {
background: oklch(0.55 0.25 290); /* vivid purple, sRGB-compatible */
color: oklch(0.98 0.01 290);
}
/* Wide-gamut P3 color, more vivid on P3 displays */
.badge-vivid {
background: oklch(0.55 0.40 150); /* vivid green, P3 only */
}
/* Explicit P3 gamut control with color() function */
.hero-gradient {
background: linear-gradient(
135deg,
oklch(0.40 0.25 290), /* dark purple */
oklch(0.75 0.35 150) /* vivid green, P3 extended */
);
}
/* CSS color() function for explicit color space */
.p3-accent {
/* display-p3 color space, explicit */
background: color(display-p3 0.2 0.8 0.4);
}
/* Fallback for sRGB displays using @supports */
@supports not (background: oklch(0 0 0)) {
.badge-vivid {
background: hsl(150, 60%, 45%); /* sRGB approximation */
}
}
5. Gradients with oklch(): no more gray midpoints
One of the most visible benefits of the CSS oklch() color space shows up in gradients between two saturated colors. In hsl() and rgb(), a gradient between complementary colors (blue and orange, for example) passes through a desaturated, gray midpoint, because the RGB channels average out geometrically to a gray tone at the center. In the CSS oklch() color space, saturation is preserved along the whole gradient. The transition from blue to orange in oklch() passes through saturated intermediate tones, typically green or purple depending on direction, which makes it look more alive and more professional than the same gradient rendered in sRGB.
Starting with Level 4, CSS supports specifying the interpolation color space directly inside the gradient function: linear-gradient(in oklch, color1, color2). This tells the browser to interpolate the gradient in the CSS oklch() color space instead of the default sRGB space. This is one of the few cases where the interpolation color space needs to be set explicitly, by default the browser interpolates gradients in sRGB even when the colors themselves are specified in oklch().
6. Design tokens with oklch() and custom properties
Combining CSS oklch() with custom properties is the most modern approach to building design token systems in CSS. Instead of storing fixed hex values or hsl() values as custom properties, the three axes of CSS oklch() get stored as separate custom properties: --brand-l: 0.55, --brand-c: 0.22, --brand-h: 290. The actual color function then combines these axes into one color. This makes it possible to adjust a single axis through JavaScript or a media query without having to redefine the whole color.
For dark mode implementations this approach is especially elegant: instead of defining a separate custom property for every dark mode hue, you can simply raise the lightness value --brand-l. oklch(var(--brand-l) var(--brand-c) var(--brand-h)) then reacts correctly to the lightness change on its own. This works noticeably better with CSS oklch() than with hsl(), because the perceptually uniform lightness axis guarantees that every hue looks equally bright in dark mode, instead of some hues appearing much darker or lighter than others at the same lightness value, as happens with hsl().
/* Design token system with oklch Custom Properties */
:root {
/* Brand color axes as separate tokens */
--brand-l: 0.55;
--brand-c: 0.22;
--brand-h: 290;
/* Composed color from axes */
--color-brand: oklch(var(--brand-l) var(--brand-c) var(--brand-h));
--color-brand-hover: oklch(calc(var(--brand-l) - 0.1) var(--brand-c) var(--brand-h));
--color-brand-muted: oklch(var(--brand-l) calc(var(--brand-c) * 0.4) var(--brand-h));
}
/* Dark mode: adjust lightness axis only */
@media (prefers-color-scheme: dark) {
:root {
--brand-l: 0.75; /* lighter in dark mode */
}
}
/* Palette generation, uniform perceptual steps */
:root {
--violet-1: oklch(0.97 0.03 290);
--violet-2: oklch(0.92 0.06 290);
--violet-3: oklch(0.83 0.10 290);
--violet-4: oklch(0.72 0.16 290);
--violet-5: oklch(0.60 0.22 290);
--violet-6: oklch(0.47 0.22 290);
--violet-7: oklch(0.36 0.18 290);
--violet-8: oklch(0.25 0.13 290);
--violet-9: oklch(0.15 0.07 290);
}
/* oklch gradient with explicit interpolation space */
.hero-bg {
background: linear-gradient(
in oklch 135deg,
oklch(0.25 0.20 290),
oklch(0.65 0.30 200)
);
}
7. The color-gamut media query: P3 fallbacks done right
The @media (color-gamut: p3) media query lets you apply P3 colors only to displays that actually support the P3 gamut. This is the recommended way to use P3 colors without sRGB displays seeing abrupt clipping artifacts. The basic approach: define baseline styles with sRGB compatible CSS oklch() colors, then override them with the more saturated P3 variants inside the color-gamut: p3 media query. This is more precise than the @supports approach, since a browser can support CSS oklch() without having a P3 display.
In practice the distinction is subtler: browsers already clip P3 colors on sRGB displays automatically, and as close to the original hue as possible. That clipping is good enough in most cases, so explicit color-gamut fallbacks are only needed for colors where clipping visibly distorts the design intent, for example very saturated greens that end up looking noticeably grayer after clipping. For everyday design work with the CSS oklch() color space, you can rely on the browser's automatic gamut compression and still end up with a better result than plain sRGB.
8. Migrating from hsl() and hex to oklch()
Migrating an existing CSS color palette to the CSS oklch() color space is not a direct numeric conversion, it is a visual reinterpretation. Converting hsl(260, 70%, 50%) to CSS oklch() produces different numbers but a perceptually similar color. Online tools such as the oklch picker by Bjorn Ottosson or the oklch.com tool show the CSS oklch() equivalent for any sRGB color. Programmatically there are converter libraries in JavaScript and Python that perform the conversion through the linear sRGB intermediate space.
The sensible migration path is not to convert every single color one by one, but to redefine the system from scratch. Brand colors get recalibrated in CSS oklch(), with evenly spaced lightness steps between palette levels instead of the often uneven spacing found in legacy hsl() palettes. The result is a color palette that looks good on both sRGB and P3 displays, has better contrast ratios and is easier to manipulate through custom properties for dark mode and themes. The one time investment of recalibrating the palette pays off through better visual consistency and easier maintenance.
| Property | hsl() | oklch() | Advantage of oklch() |
|---|---|---|---|
| Lightness axis | Not perceptually uniform | Perceptually uniform | Consistent spacing between palette steps |
| Gamut | sRGB only | sRGB + P3 + Rec2020 | More vivid colors on P3 displays |
| Gradient midpoint | Often gray or desaturated | Saturated and vivid | Visually higher quality gradients |
| Dark mode adjustment | Uneven results | Predictable via the L axis | Simpler design token logic |
| Browser support 2026 | All browsers, including IE | All modern browsers (93%+) | A @supports fallback makes sense |
9. oklch() vs. hsl() head to head
The most direct comparison between CSS oklch() and hsl() shows up when defining a color palette. An sRGB palette built in hsl() with evenly stepped lightness values (10%, 20%, 30%, and so on up to 90%) produces hues that look visually uneven, certain colors appear much lighter or darker than their numeric lightness value suggests. A CSS oklch() palette with evenly stepped L values (0.1, 0.2, and so on up to 0.9) produces hues that genuinely look evenly stepped to the eye, regardless of hue.
The second key difference is color saturation under lightness adjustments. In hsl(), saturation stays numerically constant when you change lightness, but the perceived saturation still shifts significantly, because lightness and saturation in hsl() are not independent from human perception. In the CSS oklch() color space, the chroma axis (C) is genuinely independent from the lightness axis. A color with constant C and varying L changes its perceived brightness without changing the perceived purity of the color, which enables more predictable, cleaner color systems.
Mironsoft
Design tokens, CSS color systems and frontend architecture
Want to build a color system with oklch() and design tokens?
We build modern CSS color systems on oklch(), custom properties and @layer: perceptually uniform palettes, P3 support and automatic dark mode with no manual color value calibration.
Color system audit
Analysis of existing hsl()/hex palettes for consistency and contrast criteria
oklch() palette
Rebuilding the color system with oklch() and perceptually uniform steps
Dark mode
Automatic dark mode via the oklch() lightness axis in custom properties
10. Summary
The CSS oklch() color space is fully supported in every modern browser as of 2026 and offers three fundamental advantages over hsl() and rgb(): a perceptually uniform lightness axis for consistent color palettes, access to the P3 gamut for more vivid colors on modern displays, and better gradient interpolation with no desaturated midpoints. Combined with CSS custom properties it enables elegant design token systems in which individual axes (lightness, chroma, hue) can be manipulated independently, perfect for dark mode implementations.
Migrating from hsl() and hex colors to the CSS oklch() color space is an investment that pays off through better visual consistency, easier maintenance and future proof color palettes. Explicit P3 fallbacks are possible with the color-gamut media query, but the browser's automatic gamut clipping is sufficient for most use cases. @supports (background: oklch(0 0 0)) provides a precise fallback when needed, for browsers without CSS oklch() support.
CSS oklch() Color Space: The Essentials at a Glance
L C H axes
L = lightness (0 to 1), C = chroma (0 to 0.4+), H = hue (0 to 360). Lightness is calibrated to be perceptually uniform.
P3 gamut
Chroma above 0.37 leaves sRGB. The browser clips automatically to the display gamut. Use color-gamut: p3 for explicit control.
Design tokens
L, C, H as separate custom properties. Then oklch(var(--l) var(--c) var(--h)). Dark mode via an --l adjustment inside @media.
Browser support 2026
Chrome 111+, Firefox 113+, Safari 15.4+. @supports (background: oklch(0 0 0)) for a fallback. Global support above 93%.