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.
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.
: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:
.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:
.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.