Door 17 – CSS Color Level 5 | CSS Adventskalender
Skip to content

Door 17 – CSS Color Level 5

Published: at 07:00 AM

Color tools on the web haven’t evolved for a long time. For many years, we were dependent on RGB and HSL. These are models that are not coupled to human perception and break quickly in boundary areas. With CSS Color Level 5, we finally have modern color spaces like LCH and OKLCH available, making colors more natural, consistent, and accessible.

Added to this are new functions like color-mix() and color-contrast(), which make color processing in CSS significantly more flexible.

This door shows how these new color spaces work and why they are a milestone for modern UI design.

Why RGB and HSL Are No Longer Enough

RGB and HSL have accompanied the web for years, but both models have fundamental weaknesses that are becoming increasingly problematic in modern UIs. RGB is a technical model oriented to how screens work, not to human perception. The three channels Red, Green, and Blue describe how light is mixed, but say nothing about how we actually perceive these colors. HSL (Hue, Saturation, Lightness) is visually more comprehensible, but here too the mathematics don’t align with perception.

The biggest problem is inconsistent brightness. An HSL color with 50% Lightness can appear completely differently bright depending on the hue. A vibrant yellow at hsl(60, 100%, 50%) appears much brighter than a rich blue at hsl(240, 100%, 50%), even though both theoretically have the same lightness. This makes it difficult to create color palettes where all colors appear visually balanced.

Another problem is color differences that are perceived differently strongly depending on the area of the color space. A difference of 10 units in the blue range appears different than the same difference in the yellow range. This makes creating consistent color scales difficult, whether for buttons in different states or for data visualizations.

For Dark/Light themes, RGB and HSL are particularly problematic. You can’t simply invert the lightness to switch from Light Mode to Dark Mode. Instead, separate color palettes must be created and manually adjusted, which is time-consuming and often leads to inconsistent results.

Contrasts are also hard to estimate. WCAG-compliant contrast ratios cannot be directly derived from RGB or HSL values. You must first convert the colors to luminance, which is complex and often leads to surprises. A color that visually seems to have sufficient contrast can fail accessibility standards.

Finally, color mixtures with RGB or HSL often look “muddy” or unnatural. Mixing red and green in RGB, for example, produces a dirty brown or gray instead of a harmonious transition. This is because RGB is not aligned with human color perception.

This is exactly where modern color spaces come into play. They solve these problems by orienting themselves to actual human perception.

OKLCH – The New Standard

OKLCH is a perceptually linear color space and thus represents a fundamental advance over RGB and HSL. Perceptually linear means that a numerical difference in the color space corresponds to an equally large perceived change. So if you increase the Lightness value from 0.5 to 0.6, the color becomes brighter to the human eye by the same amount as if you go from 0.7 to 0.8. This property makes OKLCH extremely predictable and intuitive.

The syntax is elegant and follows a logical structure:

color: oklch(0.65 0.15 230);

The three parameters each have a clear meaning.

The advantages of OKLCH are immediately noticeable in practice. Consistent brightness across all hues is the most important advantage. A Lightness value of 0.7 always produces the same perceived brightness, regardless of whether the color is red, green, blue, or yellow. This makes it trivial to create color palettes where all colors are visually balanced.

Linear perception means that color distances are predictable. If you want to create a scale of 5 colors, you can simply change the Lightness value in even steps (e.g. 0.3, 0.4, 0.5, 0.6, 0.7), and the colors will be evenly distributed visually. With HSL, the result would be uneven.

For Dark/Light themes, OKLCH is ideal. You can simply adjust the same color to both modes by adjusting the Lightness value, without changing saturation or hue. This ensures consistent brand identity across both themes.

OKLCH is also more accessible because Lightness values directly correlate with perception. WCAG-compliant contrast ratios are easier to achieve and verify. You can be confident that two colors with sufficiently different Lightness values actually have enough contrast.

Finally, colors look cleaner and more natural. Color mixtures produce harmonious transitions, not the muddy intermediate tones often obtained with RGB or HSL. This is because OKLCH is based on the human color perception system, not on technical display properties.

OKLCH is the best tool for professional UI design today. All modern browsers support it, and for older browsers there are clean fallbacks.

The Same Brightness Across All Hues

A concrete example immediately illustrates the difference between HSL and OKLCH. Suppose we want to create a color palette where all colors appear exactly equally bright, such as for a UI where different categories are color-coded, but no category should stand out through higher or lower brightness.

With HSL, this seems simple. Set all colors to 50% Lightness and only change the Hue value:

background: hsl(0, 80%, 50%);   /* Red */
background: hsl(200, 80%, 50%); /* Blue */

However, the result is disappointing. These colors appear completely differently bright. The red appears significantly brighter and more dominant than the blue, even though both theoretically have 50% Lightness. This is because HSL doesn’t correctly model brightness. The value 50% is a mathematical average, not a perception-based one.

With OKLCH, you actually get equally bright colors:

background: oklch(0.7 0.16 30);
background: oklch(0.7 0.16 240);

Both colors have the same Lightness value (0.7), and they actually look equally bright. This is not coincidence, but the result of mathematical modeling based on human perception. The value 0.7 corresponds to a specific perceived brightness, regardless of hue.

This difference may seem small at first glance, but is crucial in practice. For data visualizations, color-coded UI elements, or brand palettes with multiple colors, it’s essential that all colors are visually equal. With HSL, this is nearly impossible to achieve, with OKLCH it’s trivial.

Generating Shades for Light & Dark Mode Consistently

Implementing Dark Mode is traditionally one of the most time-consuming tasks when building a design system. With classic color models, you often had to create completely separate color palettes. A brand color that works in Light Mode often has too little or too much contrast in Dark Mode. So you define different colors for both modes, which leads to inconsistencies. The brand color suddenly appears different just because the user activated Dark Mode.

Additionally, most approaches require manual adjustments. You can’t simply invert colors or reverse lightness values because this leads to unusable results in RGB and HSL. Instead, each color must be individually adjusted for Dark Mode, which is time-consuming and leaves much room for error.

The result is often different contrasts between Light and Dark Mode. A color combination that works perfectly in Light Mode may contrast too weakly or too strongly in Dark Mode. This is because RGB and HSL don’t allow direct conclusions about contrast ratios.

With OKLCH, the problem is elegantly solved:

:root {
  --color-primary: oklch(0.62 0.15 260);
}

[data-theme="dark"] {
  --color-primary: oklch(0.82 0.15 260);
}

Only the Lightness is adjusted (from 0.62 to 0.82), while Chroma (0.15) and Hue (260) remain identical. The result is a color that is brighter in Dark Mode, but appears exactly identical in hue. The brand identity remains consistent, only the brightness adapts to the context.

This approach works not only for brand colors, but for the entire color system. You can take a complete Light Mode palette and simply invert the Lightness values to get a consistent Dark Mode palette. Contrasts remain stable because Lightness directly correlates with perception.

color-mix(): Mixing Colors Directly in CSS

One of the most useful new functions in CSS Color Level 5 is color-mix(). It allows mixing colors directly in CSS without relying on preprocessors or JavaScript. This sounds simple at first, but is a game-changer for dynamic color systems.

The syntax is intuitive:

color: color-mix(in oklch, var(--brand) 70%, white);

This code mixes the brand color at 70% with white at 30%. The result is a lighter variant of the brand color. Crucial is the in oklch – this performs the mixing in the OKLCH color space, leading to more natural, harmonious results than mixtures in RGB.

The big advantage: You no longer need separate tokens for each color variant. Instead of defining --brand-light, --brand-lighter and --brand-lightest, you can derive all variants on-the-fly from the base color. This significantly reduces complexity in the design system.

A typical use case is button states:

.button {
  --button-bg: var(--brand);
}

.button:hover {
  --button-bg: color-mix(in oklch, var(--brand) 85%, white);
}

.button:active {
  --button-bg: color-mix(in oklch, var(--brand) 60%, black);
}

This code creates three harmonious variants. In the hover state, the brand color is slightly lightened (85% brand, 15% white), in the active state significantly darkened (60% brand, 40% black). All three variants are automatically harmonious because the mixing occurs in the perceptually linear OKLCH color space.

This also works with Custom Properties that can be changed at runtime. If --brand changes (through theme switching or user customization), all derived variants automatically adjust. You don’t need to maintain additional tokens or manually calculate variants.

Automatically Generating Color Scales from a Base Color

The combination of OKLCH with color-mix() enables a pattern that was previously only possible with build tools or preprocessors and that is automatically generating complete color scales from a single base color. Modern design systems like Material Design or Tailwind CSS use scales with typically 9-11 gradations (e.g. 50, 100, 200, … 900). With OKLCH and color-mix(), this can be implemented directly in CSS.

The approach is elegant and maintainable:

:root {
  --brand-500: oklch(0.62 0.16 240);
  --brand-400: color-mix(in oklch, var(--brand-500) 80%, white);
  --brand-300: color-mix(in oklch, var(--brand-500) 60%, white);
  --brand-600: color-mix(in oklch, var(--brand-500) 80%, black);
  --brand-700: color-mix(in oklch, var(--brand-500) 60%, black);
}

You define a middle color (here --brand-500 with a Lightness of 0.62) and derive all other levels from it. The lighter variants (300, 400) are created by mixing with white, the darker ones (600, 700) by mixing with black. The percentages (80%, 60%) determine how strong the mixture is.

The result is a harmonious scale where all gradations appear natural and balanced. Unlike manually defined scales, there are no color jumps or inconsistencies. Each level is a mathematically exact derivation of the base color.

For Light/Dark themes, this scale works automatically. In Light Mode, you typically use the lighter gradations (300, 400, 500) for backgrounds and accents, in Dark Mode the darker ones (600, 700, 800). Since all colors are derived from the same base, the color identity remains consistent across both themes.

There are no arbitrary color jumps. Each level is evenly spaced from the previous one, both mathematically and visually. This is crucial for UI elements that have multiple states (Default, Hover, Active, Disabled). All states are clearly distinguishable but visually cohesive.

Contrasts remain consistent because OKLCH is perceptually linear. A difference of 0.1 in Lightness ensures the same perceived contrast regardless of which level of the scale you’re at. This significantly facilitates creating WCAG-compliant color combinations.

color-contrast(): Automatic Color Selection Based on Readability

Another exciting function from CSS Color Level 5 is color-contrast(). It’s still experimental and not available in all browsers, but solves a problem that repeatedly occurs in practice and that is automatically choosing a readable text color based on background color.

The function analyzes the contrast between a background color and several possible foreground colors and automatically selects the most readable option:

color: color-contrast(var(--bg) vs white, black);

This line means: Compare white and black as text colors on the background var(--bg) and choose the one that offers better contrast. The browser automatically calculates the contrast ratios and selects the most readable variant.

A typical scenario is dynamic buttons:

.button {
  background: var(--brand);
  color: color-contrast(var(--brand) vs white, black);
}

Here the brand color is used as background, and the browser automatically decides whether white or black text is more readable. This is especially valuable when --brand can be dynamically changed, through user customization or theme switching. You don’t have to manually check which text color works, the browser handles it automatically.

The function can also compare more than two options:

color: color-contrast(var(--bg) vs white, black, oklch(0.85 0.05 260));

Here three text colors are compared, and the browser selects the one with the highest contrast ratio. This is useful when you want to use brand colors for text too, but want to ensure the most readable option is always chosen.

Note: color-contrast() is still experimental and currently only supported in Safari Technology Preview. For production environments, you should currently use alternative solutions – either static color definitions or JavaScript-based contrast checks. However, the function is promising and will likely become more widely available in the coming years.

Modern Theme Systems with CSS Color Level 5

Implementing a complete theme system is significantly simplified with OKLCH. The classic problem with Dark Mode is that you can’t simply invert colors. You have to rethink the entire color system. With OKLCH, the approach is much more direct and predictable.

A minimal but functional theme system could look like this:

:root {
  --surface: oklch(0.98 0.01 0);
  --text:    oklch(0.18 0.02 260);
}

[data-theme="dark"] {
  --surface: oklch(0.18 0.01 0);
  --text:    oklch(0.95 0.02 260);
}

The logic is clear. In Light Mode, the surface is very light (0.98) and the text very dark (0.18). In Dark Mode, these values invert. The surface becomes dark (0.18) and the text light (0.95). The Chroma values (0.01 and 0.02) remain low because surfaces and text are typically nearly neutral. A slight hue (Hue 260, a slight blue) gives both a subtle coolness.

The crucial advantage is that contrasts remain consistent. The difference between Surface and Text is about 0.8 Lightness units in both modes (0.98 - 0.18 = 0.8 and 0.95 - 0.18 = 0.77). Since OKLCH is perceptually linear, this means nearly identical contrast ratios in both modes. You don’t need to perform separate WCAG tests for each mode, if it works in one mode, it works in the other.

This pattern can be extended to more complex systems:

:root {
  --surface-1: oklch(0.98 0.01 0);
  --surface-2: oklch(0.95 0.01 0);
  --surface-3: oklch(0.92 0.01 0);
  --text-primary: oklch(0.18 0.02 260);
  --text-secondary: oklch(0.45 0.02 260);
}

[data-theme="dark"] {
  --surface-1: oklch(0.15 0.01 0);
  --surface-2: oklch(0.18 0.01 0);
  --surface-3: oklch(0.22 0.01 0);
  --text-primary: oklch(0.95 0.02 260);
  --text-secondary: oklch(0.65 0.02 260);
}

Here there are multiple surface levels (e.g. for cards or elevation) and different text hierarchies. The Lightness values are systematically staggered and invert in Dark Mode, while all other properties remain consistent.

Generating Complete Color Palettes On-the-Fly

The combination of all discussed techniques enables a pattern that revolutionizes the maintainability of color systems. Generating complete color palettes directly in CSS, without build tools or preprocessors. You only define the core color, all variants are derived from it.

The basic principle:

:root {
  --brand: oklch(0.62 0.14 230);
  --brand-light: color-mix(in oklch, var(--brand) 80%, white);
  --brand-dark:  color-mix(in oklch, var(--brand) 65%, black);
}

These three lines create a color palette with three gradations. The middle color (--brand) is the source color. The light variant (--brand-light) is created by 20% mixing with white, the dark one (--brand-dark) by 35% mixing with black. All three colors are harmoniously coordinated because the mixing occurs in the OKLCH color space.

The pattern works for arbitrarily complex systems. If you want a complete palette like in Material Design or Tailwind CSS, simply extend the number of gradations:

:root {
  --brand: oklch(0.62 0.14 230);
  --brand-50:  color-mix(in oklch, var(--brand) 10%, white);
  --brand-100: color-mix(in oklch, var(--brand) 20%, white);
  --brand-200: color-mix(in oklch, var(--brand) 40%, white);
  --brand-300: color-mix(in oklch, var(--brand) 60%, white);
  --brand-400: color-mix(in oklch, var(--brand) 80%, white);
  --brand-500: var(--brand);
  --brand-600: color-mix(in oklch, var(--brand) 90%, black);
  --brand-700: color-mix(in oklch, var(--brand) 70%, black);
  --brand-800: color-mix(in oklch, var(--brand) 50%, black);
  --brand-900: color-mix(in oklch, var(--brand) 30%, black);
}

The advantage of this approach is enormous. If you want to change the brand color, you only change a single line (--brand), and all derived colors automatically adjust. This is especially valuable for white-label products where different customers use different brand colors, or for themes where users can define their own colors.

The resulting color variants are qualitatively comparable to those of professional design systems like Material Design, Tailwind CSS, or Token Studio. The difference: You don’t need build tools, no generators, no manual adjustments. Everything runs directly in the browser, at runtime.

Limits and Browser Support

Despite all advantages, there are also limits and challenges with CSS Color Level 5 that you should be aware of. The good news is: Browser support is already very good, and the technology is production-ready.

Very good support is available for the core functions.

Experimental is currently color-contrast(). This function is part of the CSS Color Level 5 specification, but is currently only supported in Safari Technology Preview. For production environments, you should therefore use alternative solutions. These are either static color definitions or JavaScript-based contrast checks. However, the function is promising and will presumably become more widely available in the coming years.

An important aspect is the fallback behavior. Older browsers that don’t understand OKLCH simply ignore the corresponding CSS rules. This means: You should always define a fallback:

.element {
  background: #3b82f6; /* Fallback for older browsers */
  background: oklch(0.62 0.18 255);
}

Browsers that don’t understand OKLCH use the first line with the hex value. Modern browsers overwrite it with the second line. This pattern works gracefully and ensures the page remains usable even in older browsers.

Alternatively, you can use @supports for explicit feature detection:

.element {
  background: #3b82f6;
}

@supports (background: oklch(0 0 0)) {
  .element {
    background: oklch(0.62 0.18 255);
  }
}

This approach is more explicit and allows implementing more complex fallback strategies when OKLCH colors are part of a larger system.

Another point is color gamuts. OKLCH can describe colors that lie outside the sRGB color space. These are such as particularly saturated or luminous colors that are only displayable on modern wide-gamut displays. On older displays, these colors are automatically mapped into the displayable range (gamut mapping), which can lead to slightly different results. In practice, this is usually uncritical because the adjustment is done intelligently.

Finally, there are performance considerations. OKLCH calculations are minimally more complex than RGB calculations because they require color conversions. In practice, however, the difference is negligible and even on low-end devices, no noticeable performance losses are to be expected.

Conclusion

CSS Color Level 5 represents a fundamental advance for color design on the web. After decades with RGB and HSL models that were never really optimized for human perception, we finally have tools based on modern insights from color science.

OKLCH enables more precise color definitions where a numerical value actually correlates with visual perception. You can say “this color should have brightness 0.7” and get a result that actually appears that bright regardless of hue. This was impossible with HSL.

Color systems are easier to scale. From a single base color, you can derive complete palettes with 9-11 gradations, all harmoniously coordinated. No manual adjustments, no trial-and-error processes, no design tools and all of this directly in CSS, at runtime.

Colors can be mixed more harmoniously. color-mix() in combination with OKLCH produces natural, clean transitions, not the muddy intermediate tones you get with RGB mixtures. This makes dynamic color systems significantly more robust.

Alignment with accessibility becomes simpler. WCAG-compliant contrast ratios are easier to achieve and verify because Lightness directly correlates with perception. color-contrast() (once it’s more widely available) will further automate these processes.

And finally, Light/Dark themes are more consistent. You can simply transfer the same color to both modes by adjusting the Lightness, without the color identity changing. The brand color remains recognizable, only the brightness adapts.

CSS Color Level 5 is production-ready today. Browser support is excellent, performance is uncritical, and the advantages are immediately noticeable. It is one of the most modern and effective tools for high-quality UI design and a tool that every modern design system should use.


☕ Buy me a coffee

If you enjoy my articles and they help you in your work, I would appreciate a "coffee" and a few kind words from you.

Buy me a coffee

Previous Post
Door 18 – Theming with CSS
Next Post
Door 16 – Variable Fonts

Comments