A Pragmatic Guide to Modern CSS Colors - Part Two: Advanced Manipulation and Mixing
Explore advanced CSS color features like relative colors, `oklch()`, and `color-mix()` to manipulate, create dynamic schemes, and achieve perceptual consistency, moving beyond simple design file value copying.
In a previous article on colors, the practical application of new CSS color features was explored for developers who typically copy color values directly from design files. Modern CSS introduces extensive color capabilities, allowing developers to achieve more nuanced and dynamic color manipulations directly in the browser than typically available in design software, thus unlocking a vast array of creative possibilities.
Manipulating Colors
The previous article introduced basic use cases of relative colors. As a quick recap, the syntax appears as follows:
:root {
--primary: #ff0000;
}
.primary-bg-50-opacity: {
background: hsl(from var(--primary) h s l / .5);
}
Crucially, in the example above, the h, s, and l components are not merely static letters but represent variables containing the hue, saturation, and lightness values derived from the original color. These letters can be replaced with specific values. For instance, #00ff00 contains no blue, so blue can be added by replacing b (which is 0 in this case) with a number:
.green-with-a-touch-of-blue {
color: rgb(from #00ff00 r g 25);
}
While functional, this approach is only effective if the original color's blue component is known. Hard-coding 25 might increase the blue for #00ff00, but for a color like #00ff55, it would decrease the value. The true power emerges when calc() is utilized:
.green-with-a-touch-of-blue {
color: rgb(from #00ff00 r g calc(b + 25));
}
Although rgb() is used here, working with it can be challenging. hsl() and oklch() often prove more versatile. With both hsl() and oklch(), the hue value ranges from 0 to 360 degrees. Values exceeding 360 degrees simply loop around, meaning adding 180 to the hue will always yield the complementary color from the opposite side of the color wheel.
:root {
--color-primary: #2563eb;
--color-secondary: hsl(from var(--color-primary) calc(h + 120) s l);
--color-tertiary: hsl(from var(--color-primary) calc(h - 120) s l);
}
For a tertiary color scheme, a similar approach applies:
:root {
--color-primary: #2563eb;
--color-secondary: hsl(from var(--color-primary) calc(h + 120) s l);
--color-tertiary: hsl(from var(--color-primary) calc(h - 120) s l);
}
This method also simplifies creating lighter or darker colors:
:root {
--primary-base: hsl(221 83% 50%);
--primary-100: hsl(from var(--primary-base) h s 10%);
--primary-200: hsl(from var(--primary-base) h s 20%);
--primary-300: hsl(from var(--primary-base) h s 30%);
/* etc */
}
While this can work, it might not always be desirable to define a base color and then apply specific lightness stops. Instead, one might prefer to lighten or darken a color incrementally, irrespective of its base lightness value. This is where calc() becomes invaluable once more:
:root {
--color-primary-base: #2563eb;
--color-primary-lighter: hsl(from var(--color-primary-base) h s calc(l + 25));
--color-primary-darker: hsl(from var(--color-primary-base) h s calc(l - 25));
}
Surface Levels
A practical application of this technique is creating surface levels. In light themes, shadows often suffice to differentiate surface layers. However, shadows are less effective on dark backgrounds. For dark themes, it's preferable to incrementally lighten each surface level, which can be achieved using light-dark() in conjunction with custom properties.
:root {
--surface-base-light: hsl(240 67% 97%);
--surface-base-dark: hsl(252 21% 9%);
/* shadows are in the codepen below */
}
.surface-1 {
background: light-dark(var(--surface-base-light), var(--surface-base-dark));
}
.surface-2 {
background: light-dark(var(--surface-base-light), hsl(from var(--surface-base-dark) h s calc(l + 4)));
}
.surface-3 {
background: light-dark(var(--surface-base-light), hsl(from var(--surface-base-dark) h s calc(l + 8)));
}
Creating a Full Color Scheme
While adjusting lightness values is useful for simple tasks, professional color scheme creation often employs what is known in design as perceptual color scaling. This involves subtle shifts in hue and saturation alongside lightness adjustments. Generally, as lightness increases, saturation should slightly increase, and hues should shift towards cooler values. Conversely, as colors get darker, saturation should decrease. This can be achieved by making small calc() tweaks to hue and saturation when generating tints and shades.
:root {
--primary-base: hsl(221 83% 50%);
--primary-400: hsl(from var(--primary-base) calc(h - 3) calc(s + 5) 60%);
--primary-300: hsl(from var(--primary-base) calc(h - 6) calc(s + 10) 70%);
/* etc */
}
When compared to versions without hue or saturation shifts, the differences become apparent, especially in lighter swatches (e.g., 100, 200, 300), where colors tend to lose vibrancy if only lightness is adjusted. This approach requires some initial setup, but once a satisfactory scheme is established, it can be effectively applied across all colors. More advanced mathematical approaches, as demonstrated by Matthias Ott at CSS Day 2024, can further enhance this.
Perceptual Colors and oklch()
One advantage of hsl() is its predictability. However, a significant drawback is that even with consistent saturation and lightness, different hues can perceptually appear brighter than others. For example, two colors might share the same saturation and lightness in hsl(), but one might offer significantly better text contrast than the other due to varying perceived brightness.
This is where oklch() becomes invaluable. oklch() operates similarly to hsl() but is based on the LCH color space (also known as HCL), specifically designed to address the perceptual consistency of colors across hues. When starting with a blue and only changing the hue value for green in oklch(), the issue of inconsistent perceived brightness is resolved.
In the LCH color model, the first value represents lightness, ranging from 0 (black) to 1 (white). Its calculation differs from HSL as it's based on perceptual lightness. Percentages can also be used. The hue, the last value, functions similarly to hsl(), with the crucial distinction that 0 in hsl() is red, whereas 0 in lch() is magenta. As a result, identical hue angles in lch() and hsl() produce noticeably different colors.
The most significant difference between the two is the Chroma value. Analogous to hsl()'s saturation, 0 chroma indicates grey, with higher numbers representing purer, more vivid colors. The Chroma scale theoretically has no upper bound, reflecting the complex nature of colors. In practice, the largest value is approximately 0.4, which corresponds to 100% if percentages are used.
While using percentages for Chroma seems ideal, the primary challenge is that its upper bound varies depending on the hue and lightness values. This variability can lead to unexpected results. Despite initial excitement for LCH in CSS, the variable upper limit of Chroma often leads developers back to hsl() for many tasks due to potential strange shifts. Nevertheless, oklch() offers distinct benefits, including a wider color gamut and, crucially, consistent perceived brightness across hues.
Determining the initial oklch() color can be challenging. Color pickers, especially those that visualize Chroma limits, are helpful. However, relative colors offer another elegant solution. Building upon a previous example of a toast notification, oklch() can enhance the design while maintaining hsl() for base colors.
.toast {
--base-color: hsl(225, 87%, 56%);
}
[data-toast="info"] {
--toast-color: oklch(from var(--base-toast-color) l c 275);
}
[data-toast="warning"] {
--toast-color: oklch(from var(--base-toast-color) l c 80);
}
[data-toast="error"] {
--toast-color: oklch(from var(--base-toast-color) l c 35);
}
As demonstrated, the oklch() version achieves greater styling consistency. This is particularly evident in borders, where contrast between border and background can fluctuate significantly in hsl() versions, along with general inconsistencies in perceived saturation.
oklch() vs lch()
CSS includes both oklch() and lch() (as well as oklab() and lab()). The LCH color space was initially developed in 1976 to better match human color perception across hues, but it had flaws, particularly in the blue and purple ranges. In 2020, OKLCH was introduced as an improved version of LCH, addressing these previous issues. For further details, specialized articles can provide in-depth information, but for practical use, simply adopting oklch() is recommended.
Mixing Two Different Colors
Relative colors excel at modifying individual channels of a single color. However, scenarios often arise where two distinct colors need to be blended. For this, the color-mix() function is available.
.purple {
color: color-mix(in srgb, red, blue);
}
Currently, a color space must be defined as the first argument (e.g., in srgb). Different color spaces can yield significantly varied results. Often, oklab or oklch are good starting points, though experimenting with others can sometimes produce desired outcomes. The CSS Working Group has recently resolved to make oklab the default, meaning browsers will eventually not require explicit color space declaration (though it will remain optional).
Controlling Color Amounts
By default, color-mix() blends colors at a 50% ratio each. This can be customized by specifying percentages for each color.
.red-with-a-touch-of-blue {
background: color-mix(in oklab, red 90%, blue);
}
.or-like-this {
background: color-mix(in oklab, red, blue 10%);
}
Transparency with color-mix()
There are two primary ways to achieve transparent values with color-mix(). The first is when the total percentage of mixed colors is less than 100%.
.semi-opaque {
background: color-mix(in oklab, red 60%, blue 20%);
}
The sum of the percentages directly determines the alpha value. In the example above, the alpha value would be 80%. If the total exceeds 100%, the numbers are normalized to a total of 100%.
Alternatively, transparent can be mixed directly:
.thiry-percent-opacity-red {
background: color-mix(in oklch, red 30%, transparent);
}
While this works, relative colors might be a more straightforward approach for simply adjusting opacity. One unique application of color-mix() is creating banded gradient effects without manually calculating intermediate values, which can be surprisingly handy for specific niche use cases.
Future Simplifications
Many of these new CSS features currently involve some repetition. Fortunately, custom functions are coming to CSS, which will help streamline these processes.
@function --lower-opacity(--color, --opacity) {
result: oklch(from var(--color) l c h / var(--opacity));
}
.lower-opacity-primary {
background: --lower-opacity(var(--primary), .5);
}
@function --shade-100(--color) returns <color> {
result: hsl(from var(--color) calc(h - 12) calc(s + 15) 95%);
}
@function --shade-200(--color) returns <color> {
result: hsl(from var(--color) calc(h - 10) calc(s + 12) 85%);
}
/* etc. */
.call-to-action {
background: --shade-200(var(--accent));
}
.hero {
background: --shade-800(var(--primary));
color: --shade-100(var(--primary));
}
Evolving Color Management
While many developers continue to copy and paste color values from design files, modern CSS empowers us to surpass the capabilities of most design applications. This includes advanced color mixing, dynamic relative colors, access to wider color gamuts, and more. Although some of these features require initial setup, once implemented, they facilitate the creation of robust and highly flexible color systems. This paradigm shift, combined with the static nature of traditional design software, prompts a fundamental question: should more design processes be conducted directly within the browser?