EduFlow
Modern CSS Mastery
0%

Section 3 · Responsive and Robust

Modern Color: oklch() and color-mix()

19 min4 min readCode playground

Lesson at a glance

Define colors in oklch() so lightness is predictable, derive tints with color-mix(), and build hover and disabled states from one base color with relative color syntax.

  1. 1Why oklch()
  2. 2Tints and shades with color-mix()
  3. 3Relative colors: edit one channel
  4. 4Your turn

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Waypoint’s four track colors were picked one at a time in a design tool, as hex codes. The Design pink looked loud, the Accessibility green looked washed out, and the white badge text passed contrast on two tracks and failed on the other two. When the team needed a pale tint for each track’s badge background, someone generated eight more hex codes by eye. Then the hover states. Twenty colors, no system, and nobody could say why #0891b2 was right.

Modern CSS color lets you define one base color per track and derive everything else with rules.

Why oklch()

Hex, rgb() and hsl() all describe colors in the sRGB space, organised around how screens emit light, not how eyes see it. HSL’s “lightness” is the worst offender: hsl(60 100% 50%) (yellow) and hsl(240 100% 50%) (blue) claim the same lightness, but the yellow is dazzling and the blue is dark. Any palette built by rotating the hue in HSL ends up with wildly different contrast.

oklch() is built on the OKLab model, designed so that numbers track perception:

  • L, lightness, from 0 (black) to 1 or 100% (white), and equal L looks equally light across hues.
  • C, chroma, from 0 (grey) up to roughly 0.37 for the most vivid colors screens can show.
  • H, hue angle in degrees: around 30 is red, 140 green, 250 blue, 350 pink.
tokens.css
:root {
  --track-design: oklch(62% 0.19 350);
  --track-perf:   oklch(62% 0.19 220);
  --track-a11y:   oklch(62% 0.19 150);
  --track-platform: oklch(62% 0.19 280);
}

Same lightness, same chroma, four hues. The tracks now carry equal visual weight, and if white text passes contrast on one, it will be very close on all four. You still verify contrast (perception models aren’t contrast formulas), but you start close instead of guessing.

Tints and shades with color-mix()

color-mix() blends two colors in a color space you choose:

CSS
.badge {
  background: color-mix(in oklch, var(--track) 15%, white);
  color: color-mix(in oklch, var(--track) 70%, black);
}
.session-card.is-cancelled {
  background: color-mix(in oklch, var(--track), transparent 85%);
}

The percentage belongs to the color it follows; the other color gets the rest. So var(--track) 15%, white is mostly white with a hint of the track: a badge background. Mixing with transparent produces a translucent version, without needing a separate alpha token.

The color space changes the result. Mixing blue and yellow in srgb passes through a muddy grey; in oklch the hue travels around the wheel and stays vivid. For UI tints, oklch or oklab gives the most even steps. color-mix() is Baseline 2023.

Relative colors: edit one channel

Relative color syntax unpacks a base color into its channels, lets you change any of them, and packs it back:

CSS
.session-card {
  --track: var(--track-design);
  border-inline-start: 6px solid var(--track);

  &:hover,
  &.is-active {
    border-color: oklch(from var(--track) calc(l - 0.15) c h);
  }

  &.is-past {
    border-color: oklch(from var(--track) l 0.03 h);   /* nearly grey, same hue */
  }

  & .badge-soft {
    background: oklch(from var(--track) l c h / 20%);  /* same color, 20% alpha */
  }
}

After from var(--track), the keywords l, c and h hold the base color’s channels as numbers, and you can use them in calc(). Note that l here is the 0 to 1 number, so subtracting 0.15 darkens by 15 points. Every derived state now follows the base color automatically: add a fifth track and its hover, past and soft variants exist the moment you define its base.

Relative colors are Baseline 2024, in Chrome, Edge, Firefox and Safari. If you must support older browsers, don’t count on a static fallback declaration written first: these values contain var(), so an older browser accepts them at parse time and then hits the invalid-at-computed-value-time trap from Custom Properties and @property, discarding your fallback too. Wrap the derived states in @supports (color: oklch(from red l c h)) { … } instead, so older browsers keep the base rule.

Your turn

The exercise card has hard-coded hex values for its badge tint and active border. Replace them with a color-mix() tint and a relative-color active state, and add a Performance track that matches the Design track’s lightness and chroma. The checks read the computed OKLCH channels. Next, you’ll take this palette into dark mode with color-scheme and light-dark().

Key takeaways

  • oklch(L C H) separates perceived lightness, chroma (colorfulness) and hue, so two colors with the same L look equally light, which HSL can’t promise.
  • A palette with fixed lightness and chroma and varying hue gives every category the same visual weight and similar contrast.
  • color-mix(in oklch, var(--track) 15%, white) makes a tint from any base color, and the color space you mix in changes the result.
  • Relative color syntax, such as oklch(from var(--track) calc(l - 0.15) c h), derives a state color by editing one channel of the base color.

Further reading

Check your understanding

4 questions

Question 1 of 4

hsl(60 100% 50%) and hsl(240 100% 50%) have the same lightness value. Why does white text read well on one and badly on the other?

Question 2 of 4

What does color-mix(in oklch, var(--track) 20%, white) produce when --track is a saturated pink?

Question 3 of 4

Which declaration gives a hover state 0.1 darker than --track but with the same chroma and hue, whatever --track is?

Question 4 of 4

Your track colors are written as oklch(62% 0.25 150), and on an older sRGB laptop the green looks slightly different from the designer’s P3 monitor. What happened?

Practice

Code playground

Derive the track colors

The Design track’s base color is --track: oklch(62% 0.19 350), but its badge tint and active border are hand-picked hex codes. Replace them with rules that work for any track:

  1. Badge background: mix 15% of --track with white, in oklch.
  2. Active card (.is-active) border: the track color with lightness 0.15 lower, same chroma and hue, using relative color syntax.
  3. Add a .track-perf class whose --track has the same lightness and chroma as Design, with hue 220.

The checks read the computed OKLCH values, and also activate the Performance card to make sure your rules aren’t specific to one track.

<article class="session-card track-design is-active" id="design">
  <h3>Designing in oklch()</h3>
  <span class="badge">Design</span>
</article>
<article class="session-card track-perf" id="perf">
  <h3>The Cost of a Frame</h3>
  <span class="badge">Performance</span>
</article>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • The Design badge is a 15% tint of the track color
  • The active border is 0.15 darker than the track
  • The Performance track has the same L and C with hue 220
  • The Performance badge tint follows its own track
  • An active Performance card gets its own darker border