Skip to content

UX-09 · UX · Digital Product Design

CSS's new era of color

2,855 words · about 19 min listening

Read the Turkish original

Glance at the summary firstThe summary from the end of the lesson, as a road map before you start reading.
  1. Tonal scales built with oklch() are more balanced than HSL's, because OKLCH's lightness is measured according to perception; in HSL, a yellow scale's steps stick together in the light half and come apart in the dark half.
  2. color-mix() mixes two colors in a chosen space, and relative syntax derives a new color from an existing one; both tie a design system's derived colors to a single source and produce a shadow or accent from a source like skin tone with the same perceptual relationship.
  3. An sRGB fallback is written for P3 and out-of-gamut colors; support is checked before publishing against a dated source like web-features: oklch() and color-mix() are widely available, relative syntax is newly available, and gamut mapping isn't yet Baseline.

Short answer

CSS can now write colors not only with sRGB's hexadecimal codes or with HSL, but in the perceptually ordered OKLCH space, with color-mix(), which blends two colors in a chosen space, and with relative syntax, which derives a new color from an existing one; wider-gamut colors like P3 can also be defined with the color() function. Tonal scales built with oklch() are more balanced than HSL's, because OKLCH's lightness axis is measured close to how the eye perceives lightness: equal numerical steps also look approximately equal to the eye, whereas in HSL, yellow and blue at the same "l" value have very different lightness, and the steps of a yellow scale stick together in one half and open up like a cliff in the other. Support for these tools has expanded quickly but unevenly; current support status is checked before publishing, and an sRGB fallback value is written for wide-gamut colors.

Why HSL's lightness misleads

As you saw in UX-01, the purpose of a tonal scale is for its steps to descend to the eye like a regular staircase. HSL can't do this job, because the "l" value in HSL is defined not by perception but by a simple average of the RGB channels; EK-02 explains this formula itself.

The numbers show the problem clearly. When you measure the OKLab lightness of six colors written in HSL with full saturation and fifty percent lightness, red comes out at about 0.63, yellow at 0.97, green at 0.87, cyan at 0.91, blue at 0.45, and magenta at 0.70. The same "l" value hides a lightness difference of more than half. White text gives a contrast ratio of about 1.07:1 on this yellow and about 8.6:1 on this blue.

This difference doesn't pay its real cost in individual colors, but in scales. When a yellow scale at a 55-degree hue and ninety percent saturation in HSL is built by stepping the "l" value down from 90 to 20 in increments of ten, its OKLab lightness drops by only about 0.02 at each of the first four steps: 0.97, 0.95, 0.93, 0.91, 0.89. Then it suddenly starts dropping by about 0.14 at each step: 0.76, 0.61, 0.46. The five steps in the scale's light half look to the eye like nearly the same shade of yellow; the steps in the dark half, by contrast, are disconnected from each other. A blue scale built with the same method behaves much more evenly, but that's luck, not a success of the method.

oklch(): perceptually ordered axes

OKLCH is the polar notation of the OKLab space; the space itself and how it's built are the subject of BI-24. What matters in this lesson is how it's used in CSS. A blue is written in the form oklch(0.56 0.12 255): the first number is lightness, the second is chroma, the third is hue angle.

The same yellow problem disappears here. When a blue scale at a 255-degree hue and 0.12 chroma is built with lightness descending from 0.92 to 0.30 in equal steps, dropping by 0.09 at each step, every step's measured lightness lands exactly on its target value. The designer doesn't have to adjust the scale by eye; equal steps already look equal. That's why it's no coincidence, as noted in UX-01, that Tailwind CSS's default palette is written in OKLCH.

OKLCH has a cost, and it connects to the out-of-gamut color concept defined in GT-06: not every combination of lightness and chroma falls within sRGB. The lightest two steps of the blue scale above, at 0.92 and 0.83 lightness, and its darkest step, at 0.30, spill outside sRGB at a chroma of 0.12. The CSS Color 4 specification defines a method for how out-of-gamut colors are brought back into gamut; but according to web-features data, this gamut mapping isn't yet Baseline in any browser, and browsers most often clip these colors by cutting their channels. Clipping can change a color's hue and lightness unexpectedly. The practical fix is to reduce chroma at the scale's ends; the chroma sRGB can carry narrows as you approach the light and dark ends.

color-mix() and relative syntax

The CSS Color 5 specification adds two tools for computing with colors. The specification's own rationale is that as design systems support multiple platforms and user preferences like dark mode, computing colors from a single source is becoming more useful than specifying them one by one by hand.

A syntax like color-mix(in oklch, var(--primary) 80%, black) mixes the primary color with twenty percent black in the OKLCH space. Which space the mix is done in matters: when the same two colors are mixed in sRGB, the intermediate color can look muddy and grayed out, while mixing in OKLCH or OKLab gives a more balanced transition. The state layers in UX-04 can be produced with color-mix() in a single line, without writing a separate color for every component.

The syntax oklch(from var(--primary) calc(l - 0.1) c h) keeps the primary color's chroma and hue, and lowers its lightness by 0.1. One of the specification's own examples shows that the syntax oklch(from var(--color) l 0 h) keeps lightness and zeroes out chroma, giving the color's gray at the same lightness. With this syntax, all of a design system's derived colors, like UX-01's role names, can be tied to a single source color and update automatically when the source changes.

P3 colors and fallback

A P3 color is written in CSS as color(display-p3 1 0.5 0); the same color can also be written with oklch(), using a chroma outside sRGB. Whether the screen can display this gamut is found out with the @media (color-gamut: p3) query. Screen gamut and screen technologies are the subject of the EK series; what matters here is that a wide-gamut color won't look the same to every user.

The simplest way to set up a fallback is to write the same property twice: first an sRGB hex value, then immediately after it the OKLCH or P3 value. A browser that understands the new value uses it; one that doesn't stays with the first. More complex cases use the @supports rule. The fallback's color should be the closest sRGB equivalent of the wide-gamut color; GT-10's record-keeping logic also applies here: the P3 color is one derived value for a screen, the sRGB fallback is another.

Checking support before publishing

The lesson card has a note: browser support must be checked before publishing. These tools spread rapidly within a few years, and the situation can change between the day a lesson is written and the day it's read. That's why this lesson bases support on a dated source.

The practical rule that follows from this table is: oklch() and color-mix() can comfortably be used today with a fallback; relative syntax should be used with a carefully built fallback, because older browsers that don't support it are still in circulation; and how out-of-gamut colors will look must additionally be checked on every browser. The specifications themselves are also still moving: CSS Color 4 is at Candidate Recommendation Draft, while CSS Color 5 is at Working Draft level.

Perceptual lightness and contrast ratio

OKLCH's lightness and the WCAG contrast ratio from UX-06 don't measure the same thing. Contrast ratio rests on relative luminance calculated from sRGB channels; OKLCH's lightness, meanwhile, is the axis of a perceptually ordered model. The two track close to each other, but don't overlap exactly, and this difference matters at the margin.

A trial shows this. Five colors with a lightness of 0.56 and a chroma of 0.12, written at five different hues, give the following contrast ratios when white text is placed over each: about 4.93:1 at a reddish hue, 4.66:1 at a yellowish hue, 4.45:1 at a green hue, 4.66:1 at blue, and 4.95:1 at a purplish hue. The range is narrow; the same trial in HSL had given a spread between 1.07 and 8.6. But the green color falls just below the 4.5-to-one threshold expected for small text. The yellowish one, meanwhile, spills outside sRGB and was measured in its clipped form.

The lesson that follows from this is that a palette built with OKLCH doesn't replace contrast. Equal lightness largely equalizes contrast across hues and makes a design system predictable; but every color pair near the threshold is still measured with the WCAG formula. The choice of L* for the lightness axis of the HCT space in UX-05 comes from the same concern: letting contrast be read as directly as possible from lightness.

Example — the avatar generator's skin tone palette

An app lets users create their own line-drawn avatars and offers six skin tones. In the first version, the skin tones were written in HSL: a 25-degree hue, forty-five percent saturation, and lightness in equal steps from 85 to 25. When measured, these six skin tones' OKLab lightness values come out at 0.89, 0.80, 0.71, 0.62, 0.51, and 0.39: the steps are around 0.09 at the light end, growing to 0.12 at the dark end. The difference isn't dramatic, but the step between dark skin tones is noticeably bigger than between light ones; a gap opens up between the two darkest steps where a user might not find an option close to their own skin.

The team rewrites the palette with oklch(): a 55-degree hue, 0.08 chroma, and six steps with lightness descending from 0.85 to 0.35 in steps of 0.10. The chosen range isn't arbitrary: the OKLab lightness values of four skin samples the team measured from light to dark are 0.86, 0.68, 0.50, and 0.33, with chroma between 0.05 and 0.09. The new palette's six steps cover this range in equal increments and all stay within the sRGB gamut, meaning no browser clips them.

Relative syntax derives the rest of the avatar from a single source. The cheek shadow is written as oklch(from var(--skin) calc(l - 0.08) c h), and the lip color as oklch(from var(--skin) l calc(c + 0.03) calc(h - 20)), its hue shifted slightly toward red. Whatever skin tone the user picks, the shadow and lip are tied to it with the same perceptual relationship; the shadow doesn't disappear on light skin tones or turn muddy on dark ones. For browsers that don't support relative syntax, each skin tone's shadow is also written with a fixed hex value, as a fallback. The result is checked side by side across all six skin tones, in light and dark theme, on old and new browsers.

Common misconception

Common mistake"The same lightness value in HSL means the same brightness for every color."
Correction

HSL's lightness is defined not by perception but by the average of the RGB channels; at fifty percent lightness, yellow's OKLab lightness is about 0.97, and blue's is about 0.45.

Common mistake"With oklch() I can write whatever chroma I want, and the browser handles the rest."
Correction

Many combinations of lightness and chroma fall outside sRGB, and CSS's gamut-mapping behavior isn't yet Baseline; browsers most often clip these colors, and the color can change unexpectedly. Chroma must be reduced at the scale's ends, and the result must be checked on real browsers.

Summary

  1. Tonal scales built with oklch() are more balanced than HSL's, because OKLCH's lightness is measured according to perception; in HSL, a yellow scale's steps stick together in the light half and come apart in the dark half.
  2. color-mix() mixes two colors in a chosen space, and relative syntax derives a new color from an existing one; both tie a design system's derived colors to a single source and produce a shadow or accent from a source like skin tone with the same perceptual relationship.
  3. An sRGB fallback is written for P3 and out-of-gamut colors; support is checked before publishing against a dated source like web-features: oklch() and color-mix() are widely available, relative syntax is newly available, and gamut mapping isn't yet Baseline.

Test yourself

  1. (Easy) What do the three numbers in the oklch() function show, in order?
  2. (Easy) According to web-features 3.40.0, on what date did relative color syntax become Baseline "newly available"?
  3. (Medium) Why are tonal scales built with oklch more balanced than HSL's?
  4. (Medium) How is a fallback set up for a wide-gamut P3 color?
  5. (Hard) What problem comes up at the lightest and darkest steps of an OKLCH blue scale with 0.12 chroma, and how is it solved?
  6. (Hard) What problem does rebuilding the avatar generator's skin tone palette with oklch() and relative syntax solve, and why is an additional fallback needed?

Answers

  1. Lightness, chroma, and hue angle ("oklch(): perceptually ordered axes").
  2. September 16, 2024 ("Checking support before publishing").
  3. OKLCH's lightness axis is measured close to how the eye perceives lightness, so equal numerical steps give approximately equal perceptual steps. HSL's lightness is the average of the RGB channels; at the same "l" value, yellow sits at about 0.97 and blue at about 0.45 OKLab lightness, and in an HSL yellow scale the steps in the light half are spaced about 0.02 apart while those in the dark half are spaced about 0.14 apart ("Why HSL's lightness misleads", "oklch(): perceptually ordered axes").
  4. First the closest sRGB equivalent is written as a hex value, then immediately after, the same property is written again with the P3 or OKLCH value; a browser that doesn't understand it stays with the first value. If needed, the @supports or color-gamut query is used ("P3 colors and fallback").
  5. The lightest two steps and the darkest step spill outside the sRGB gamut; because CSS's gamut-mapping behavior isn't yet Baseline, browsers most often clip these colors, and the color can change unexpectedly. Chroma is reduced at the scale's ends, and the result is checked on real browsers ("oklch(): perceptually ordered axes").
  6. The skin tones written in HSL didn't have equal perceptual steps, and a gap opened up at the dark end; with oklch(), the six skin tones cover the range of the measured skin samples in equal steps and within gamut. The shadow and lip colors are tied to the skin tone with the same perceptual relationship through relative syntax. Because relative syntax is still only newly available, fixed hex fallbacks are written for browsers that don't support it ("Example — the avatar generator's skin tone palette").

Sources

  • W3C, "CSS Color Module Level 4," Candidate Recommendation Draft.
  • W3C, "CSS Color Module Level 5," Working Draft.
  • web-platform-dx, "web-features," version 3.40.0, data.json.
  • Tailwind CSS, "Colors," official documentation.

Next step

UX-10 carries screen color decisions into a game world: it asks why climbable surfaces in games are so often marked with the same color, how the palette changes from one region to another, and how the colors that distinguish friend from foe are rebuilt for color blindness modes. EK-01, meanwhile, descends to the final stop of every color in this lesson: it shows whether there's really yellow light inside the yellow we see on screen, and how three-color subpixels and bit depth produce millions of colors.

Animation and narration notes

Animation and narration notes

Video flow

  • 0:00–0:40 Hook: A single value changes in a code editor, and the whole theme transforms at once.
  • 0:40–2:30 Story: A yellow scale built with HSL; its light half is almost a single color, its dark half broken apart step by step.
  • 2:30–5:30 Explanation: The same scale is rebuilt with oklch(); the lightness axis descends in equal steps; the steps that spill out of gamut at the ends are marked with a red frame and brought back in by reducing chroma.
  • 5:30–7:30 color-mix() and relative syntax: State layers, shadows, and a gray equivalent are derived from a source color in single lines.
  • 7:30–9:00 P3 and fallback: Wide-gamut and sRGB screens side by side; the same page shows two different oranges on the two screens, and the fallback kicks in.
  • 9:00–10:30 Support table: Features settle into the "newly" and "widely" available steps on a dated chart.
  • 10:30–11:40 Real world: The avatar generator; the uneven skin-tone steps in HSL are equalized with oklch(), and the shadow and lip colors are tied to every skin tone with the same relationship.
  • 11:40–12:00 Closing: The UX-10 and EK-01 codes.

Visual notes

  • The code editor, avatar generator, and interfaces are fictional; no specific product's screen is copied.
  • The avatars are drawn by the team; the six skin-tone steps are taken from the oklch() values in the lesson text, and the four measured skin samples are shown on screen for comparison.
  • Support dates are written on screen together with the source version; the video is rechecked against current data before publishing.