Chapter 7 — Advanced Color: oklch, color-mix(), and Color Spaces
CSS has supported color since 1996 but for most of that time browsers worked in a
single color space — sRGB — with two authoring models: hex/rgb and hsl. Modern monitors
can display colors that sRGB cannot represent, and HSL has a fundamental flaw: two colors
with the same L value look dramatically different in brightness to the human eye.
The CSS Color Level 4 and Level 5 specifications fix both problems.
Baseline 2023. oklch(), color-mix(), display-p3, and relative color
syntax are supported in all evergreen browsers (Chrome 111+, Firefox 113+, Safari 15.4+).
light-dark() landed in Baseline 2024. Use @supports for graceful fallbacks where
needed — older browsers still receive your fallback color, not a broken layout.
1. Why HSL Fails as a Design Tool
HSL (Hue, Saturation, Lightness) was designed for programmer convenience, not
perceptual accuracy. Its Lightness axis is mathematically uniform but
perceptually wrong: yellow at 50% L appears far brighter than blue at 50% L.
This breaks color scales, makes accessible contrast hard to predict, and produces
jarring hover states when you only change the L value.
HSL — programmer model
H
Hue (0–360°) Position on the color wheel. Not uniform — hue steps look different sizes in different regions.
S
Saturation (0–100%) How vivid vs grey. Interacts with L non-linearly.
L
Lightness (0–100%) Mathematical midpoint. Yellow at 50% L looks almost white; blue at 50% L looks dark.
Problem: hsl(60 100% 50%) yellow vs hsl(240 100% 50%) blue — same L, wildly different perceived brightness.
oklch — perceptual model
L
Lightness (0–1) Perceptually uniform. Colors with the same L value look equally bright to the human eye.
C
Chroma (0–0.37+) How vivid the color is. 0 = grey. Max varies by hue and the gamut of the display.
H
Hue (0–360°) Position on the color wheel. Perceptually uniform — equal hue steps look equal in size.
Result: changing only L gives truly lighter/darker variants. Building accessible palettes becomes predictable.
2. oklch() Syntax
/* oklch(L C H) */
/* oklch(L C H / alpha) */
oklch(0.7 0.15 250)
/* L C H */
/* │ │ └─ hue in degrees (0–360, wraps) */
/* │ └────── chroma: 0 = grey, ~0.37 = maximum sRGB-safe for most hues */
/* └─────────── lightness: 0 = black, 1 = white (or 0%–100%) */
/* Percentage syntax for L */
oklch(70% 0.15 250) /* same as 0.7 */
/* With alpha */
oklch(0.7 0.15 250 / 50%) /* 50% opacity */
oklch(0.7 0.15 250 / 0.5) /* same */
/* none — the missing-component value */
oklch(0.7 none 250) /* chroma = 0, treated as grey for interpolation */
/* Key hue landmarks */
/* 0° / 360° = red */
/* 30° = orange */
/* 60° = yellow */
/* 120° = green */
/* 180° = cyan */
/* 220–260° = blue (most UI blues here) */
/* 300° = magenta/pink */
/* Practical named references */
--color-blue: oklch(0.62 0.18 248); /* GitHub-ish blue */
--color-green: oklch(0.68 0.19 145);
--color-red: oklch(0.63 0.22 25);
--color-amber: oklch(0.72 0.17 65);
--color-purple: oklch(0.65 0.20 300);
3. Building a Perceptually Uniform Color Scale
The value of oklch for design systems: you can generate a full 50–950 scale
by varying only L, keeping C and H constant. Each step looks the same perceptual
distance from the one below it.
Blue scale — H=248, C=0.18, L varies from 0.18 to 0.96
950
900
800
700
600
500
400
300
200
100
50
/* A complete scale using CSS custom properties */
:root {
/* Blue scale — H fixed at 248, C fixed at 0.18 */
--blue-950: oklch(0.18 0.18 248);
--blue-900: oklch(0.26 0.18 248);
--blue-800: oklch(0.34 0.18 248);
--blue-700: oklch(0.42 0.18 248);
--blue-600: oklch(0.50 0.18 248);
--blue-500: oklch(0.60 0.18 248); /* base */
--blue-400: oklch(0.70 0.18 248);
--blue-300: oklch(0.78 0.18 248);
--blue-200: oklch(0.85 0.18 248);
--blue-100: oklch(0.91 0.18 248);
--blue-50: oklch(0.96 0.18 248);
/* Multi-hue palette — same L and C, only H changes */
/* All five of these have the same perceived lightness */
--brand-blue: oklch(0.62 0.18 248);
--brand-green: oklch(0.62 0.18 145);
--brand-red: oklch(0.62 0.18 25);
--brand-amber: oklch(0.62 0.18 65);
--brand-purple: oklch(0.62 0.18 300);
}
Same L=0.62, C=0.18 — only H changes. Equal perceived brightness.
Blue 248
Green 145
Red 25
Amber 65
Purple 300
4. color-mix()
color-mix() blends two colors together in a specified color space.
The color space choice matters enormously — the same mix in oklch vs sRGB produces
noticeably different results, especially for complementary colors.
Blue
oklch(0.62 0.18 248)
+
=
50% mix
color-mix(in oklch, ... 50%, ...)
/* color-mix(in <color-space>, <color1> [pct], <color2> [pct]) */
/* Basic mix — equal halves */
color-mix(in oklch, blue 50%, red)
/* Asymmetric mix — 30% of first color, 70% of second */
color-mix(in oklch, blue 30%, red 70%)
/* Single percentage — the remainder is taken from the second color */
color-mix(in oklch, blue 30%, red) /* same as above */
/* Color spaces: oklch, oklab, srgb, hsl, hwb, lab, lch, display-p3 */
color-mix(in srgb, blue, red) /* produces muddy purple */
color-mix(in oklch, blue, red) /* travels the hue wheel — cleaner midpoint */
/* Hue interpolation methods for oklch and lch */
color-mix(in oklch shorter hue, blue, red) /* default — shortest path on hue wheel */
color-mix(in oklch longer hue, blue, red) /* longest path — goes through more hues */
color-mix(in oklch increasing hue, blue, red) /* always increasing H value */
/* Practical: tinting towards white (mix with a very-high-L neutral) */
--btn-hover-bg: color-mix(in oklch, var(--brand-blue) 80%, white);
--btn-active-bg:color-mix(in oklch, var(--brand-blue) 80%, black);
/* Creating opacity-equivalent using color-mix */
/* color-mix(in srgb, blue 20%, transparent) ≈ blue at 20% opacity */
--overlay: color-mix(in srgb, black 60%, transparent);
/* Generating a tonal surface from a brand color */
:root {
--brand: oklch(0.55 0.22 248);
--surface-brand: color-mix(in oklch, var(--brand) 12%, oklch(0.1 0 0));
--border-brand: color-mix(in oklch, var(--brand) 40%, oklch(0.1 0 0));
/* Both surface and border derive from the same brand token */
}
5. Color Spaces, Gamuts, and display-p3
A color space is a mathematical model for representing colors.
A gamut is the range of colors a color space (or device) can express.
Modern displays can show colors that sRGB cannot — the P3 gamut used by Apple and most
high-end screens covers roughly 50% more colors than sRGB.
oklch
Not a gamut — a perceptual color model that can reference any gamut
all
/* oklch can reference colors outside sRGB gamut */
/* C (chroma) > ~0.37 may be out of sRGB gamut for some hues */
oklch(0.65 0.30 145) /* vivid green — outside sRGB, inside P3 */
oklch(0.65 0.37 145) /* more vivid — may exceed even P3 on some hues */
/* display-p3 — explicit P3 color space syntax */
color(display-p3 0.2 0.6 1) /* P3 triplet — brighter blue than sRGB allows */
/* ── Progressive enhancement with @media (color-gamut) ─────── */
:root {
/* Fallback for standard displays */
--accent: oklch(0.62 0.18 248);
}
@media (color-gamut: p3) {
:root {
/* Richer blue only on P3-capable displays */
--accent: oklch(0.62 0.26 248); /* higher C, outside sRGB */
}
}
@media (color-gamut: rec2020) {
:root {
/* Maximum vividness for HDR displays */
--accent: oklch(0.62 0.32 248);
}
}
/* color-gamut values: srgb | p3 | rec2020 (progressive: p3 matches p3 AND rec2020) */
/* Gamut mapping — browser behaviour when a color is out of gamut */
/* Browsers automatically gamut-map out-of-gamut oklch values to the nearest */
/* in-gamut color on screens that can't display the specified color. */
/* You get the best available approximation — nothing breaks. */
6. Relative Color Syntax
Relative color syntax lets you derive a new color from an existing one by reading
out its channel values and modifying them with CSS math functions. This is the
feature that unlocks truly dynamic, token-driven theming without JavaScript.
/* Syntax: color-function(from <origin-color> channel1 channel2 channel3) */
/* Read the origin color's channels into named variables l, c, h */
oklch(from var(--brand) l c h) /* exact copy */
oklch(from var(--brand) calc(l + 0.15) c h) /* lighter variant */
oklch(from var(--brand) calc(l - 0.15) c h) /* darker variant */
oklch(from var(--brand) l calc(c * 0.5) h) /* less saturated */
oklch(from var(--brand) l c calc(h + 30)) /* hue-shifted 30° */
/* Channel names vary by color space: */
/* oklch: l, c, h */
/* oklab: l, a, b */
/* hsl: h, s, l */
/* rgb/srgb: r, g, b */
/* display-p3: r, g, b */
/* Practical: derive all interactive states from one token */
:root {
--btn-bg: oklch(0.55 0.22 248);
--btn-hover: oklch(from var(--btn-bg) calc(l + 0.08) c h);
--btn-active: oklch(from var(--btn-bg) calc(l - 0.08) c h);
--btn-text: oklch(from var(--btn-bg) max(0.95, calc(l + 0.4)) 0.02 h);
/* --btn-text ensures contrast by pushing L near 0.95 — near-white */
}
/* Cross-space derivation — read from one space, output to another */
oklch(from rebeccapurple l c h) /* named color → oklch */
oklch(from #58a6ff l c h) /* hex → oklch */
oklch(from hsl(220 80% 60%) l c h) /* hsl → oklch */
/* alpha channel — available as 'alpha' in all color spaces */
oklch(from var(--brand) l c h / 0.5) /* 50% transparent */
oklch(from var(--brand) l c h / alpha) /* preserve original alpha */
Five states derived from one base token: oklch(0.55 0.22 248)
Active
L-0.20
Pressed
L-0.08
Base
L=0.55
Hover
L+0.08
Disabled
C×0.27
7. light-dark()
light-dark() is a CSS function that selects between two values based
on whether the user prefers a light or dark color scheme. It requires
color-scheme to be declared on the element or its ancestors — typically
set on :root.
Light color-scheme
Component card
Automatically uses the light values from light-dark() calls without any @media override.
Dark color-scheme
Component card
Same CSS — automatically uses the dark values. No @media (prefers-color-scheme) block needed.
/* light-dark(light-value, dark-value) */
/* color-scheme must be set on :root (or the element) */
:root {
color-scheme: light dark; /* tell the browser both schemes are supported */
}
:root {
--bg: light-dark(oklch(0.97 0.01 248), oklch(0.10 0.01 248));
--surface: light-dark(oklch(1.00 0.00 0), oklch(0.14 0.01 248));
--text: light-dark(oklch(0.15 0.01 248), oklch(0.92 0.01 248));
--border: light-dark(oklch(0.85 0.02 248), oklch(0.22 0.02 248));
--accent: light-dark(oklch(0.50 0.22 248), oklch(0.70 0.18 248));
/* Note: lighter accent in dark mode for contrast against dark surface */
}
/* Switching scheme for a specific section */
.dark-panel {
color-scheme: dark; /* forces dark values for light-dark() in this subtree */
}
/* vs the older approach — more verbose, duplicate selectors */
:root { --bg: oklch(0.97 0.01 248); }
@media (prefers-color-scheme: dark) {
:root { --bg: oklch(0.10 0.01 248); }
}
/* light-dark() collapses this to one declaration */
8. Color Interpolation in Gradients and Animations
/* Gradients can specify which color space to interpolate in */
/* This dramatically affects midpoint quality */
/* Default sRGB interpolation — can produce muddy grey midpoints */
linear-gradient(to right, blue, yellow)
/* oklch interpolation — travels the hue wheel, no grey midpoint */
linear-gradient(in oklch, blue, yellow)
/* Controlling hue travel direction */
linear-gradient(in oklch shorter hue, oklch(0.6 0.2 0), oklch(0.6 0.2 200))
linear-gradient(in oklch longer hue, oklch(0.6 0.2 0), oklch(0.6 0.2 200))
/* Rainbow gradient using hue wheel */
.rainbow {
background: linear-gradient(
in oklch longer hue,
oklch(0.65 0.22 0),
oklch(0.65 0.22 360)
);
}
/* Animations: @keyframes interpolate in sRGB by default */
/* Use color-interpolation-method to change this */
@keyframes pulse {
from { color: oklch(0.6 0.22 248); }
to { color: oklch(0.6 0.22 300); }
}
/* Both keyframe values are in oklch — the browser interpolates correctly */
/* CSS Animations Level 2 will add color-interpolation-method to @keyframes */
9. A Complete oklch Token System
Brand palette: H=248 (blue), H=145 (green), H=25 (red), H=65 (amber) — C=0.18 throughout
950
900
800
700
600
500
400
300
200
100
50
950
900
800
700
600
500
400
300
200
100
50
950
900
800
700
600
500
400
300
200
100
50
/* Complete design token system using oklch + relative color + light-dark() */
:root {
color-scheme: light dark;
/* ── Primitive tokens (the palette) ─────────────────────────── */
--blue-500: oklch(0.60 0.18 248);
--blue-400: oklch(0.70 0.18 248);
--blue-200: oklch(0.85 0.18 248);
--green-500: oklch(0.60 0.18 145);
--red-500: oklch(0.60 0.18 25);
--amber-400: oklch(0.78 0.18 65);
--grey-950: oklch(0.12 0.01 248);
--grey-100: oklch(0.94 0.01 248);
--grey-50: oklch(0.98 0.01 248);
/* ── Semantic tokens (light / dark) ──────────────────────────── */
--color-bg: light-dark(var(--grey-50), var(--grey-950));
--color-surface: light-dark(oklch(1 0 0), oklch(0.17 0.01 248));
--color-text: light-dark(oklch(0.15 0 0), oklch(0.90 0.01 248));
--color-text-sub: light-dark(oklch(0.45 0 0), oklch(0.60 0.01 248));
--color-border: light-dark(oklch(0.85 0.02 248),oklch(0.24 0.02 248));
--color-accent: light-dark(var(--blue-500),var(--blue-400));
--color-success: light-dark(var(--green-500),oklch(0.72 0.18 145));
--color-danger: light-dark(var(--red-500), oklch(0.72 0.18 25));
/* ── Component tokens derived from semantic ──────────────────── */
--btn-bg: var(--color-accent);
--btn-hover: oklch(from var(--color-accent) calc(l + 0.07) c h);
--btn-active: oklch(from var(--color-accent) calc(l - 0.07) c h);
--btn-ring: oklch(from var(--color-accent) l c h / 0.4);
}
Chapter Summary
| Concept | Key point |
| oklch(L C H) | Perceptually uniform color space. L: 0–1 lightness; C: 0–0.37+ chroma; H: 0–360° hue. Equal L values look equally bright across all hues — HSL does not guarantee this. |
| Color scales | Build a full 50–950 scale by varying only L while holding C and H constant. Each step is a perceptually equal distance from the adjacent one. Ideal for design tokens. |
| color-mix() | color-mix(in oklch, color1 pct, color2) blends two colors in a chosen color space. The color space changes the midpoint dramatically — oklch avoids muddy grey midpoints. Supports hue interpolation keywords: shorter/longer/increasing/decreasing hue. |
| display-p3 | Color space covering ~50% more colors than sRGB. Supported on most modern screens. Use color(display-p3 r g b) syntax or high-C oklch values. Browsers gamut-map out-of-gamut colors automatically — nothing breaks on older screens. |
| @media (color-gamut) | Values: srgb, p3, rec2020. Progressive enhancement: set sRGB-safe colors as default, override with richer P3 colors inside the media query. p3 matches both p3 and rec2020 displays. |
| Relative color syntax | oklch(from <origin> l c h) reads channel values from an existing color and lets you manipulate them with calc(). Derive hover/active/disabled states from one base token. Works across color spaces. |
| light-dark() | light-dark(light-val, dark-val) selects based on prefers-color-scheme. Requires color-scheme to be declared on :root. Eliminates separate @media (prefers-color-scheme: dark) blocks for color values. Use color-scheme: dark on a subtree to force dark values in that section. |
| Gradient interpolation | linear-gradient(in oklch, ...) interpolates in oklch space. Cleaner midpoints than sRGB default. Add shorter/longer hue keywords to control which direction the hue wheel travels. |
| Token architecture | Three tiers: primitive (the full palette), semantic (light-dark mapped tokens), component (derived from semantic via relative color). Change one primitive and all derived values update. |
Exercises
- Build a full oklch palette: Choose a brand hue (any H value that isn't already a web standard color). Generate 11 steps (50–950) by varying L from 0.96 down to 0.18 in even increments, keeping C at 0.18. Render all 11 swatches. Then repeat with C=0.10 for a muted tonal version. Compare the two scales side by side and note where sRGB gamut clipping occurs (if at all) at your chosen hue.
- color-mix() hover states: Create a button component that uses only a single
--btn-color custom property. Derive the hover background using color-mix(in oklch, var(--btn-color) 80%, white), the active background using color-mix(in oklch, var(--btn-color) 80%, black), and a focus ring using color-mix(in srgb, var(--btn-color) 40%, transparent). Verify you can change the button's color by updating just one token.
- Relative color interaction states: Rewrite the exercise above using relative color syntax instead of color-mix. Use
oklch(from var(--btn-color) calc(l + 0.1) c h) for hover and calc(l - 0.1) for active. Compare the visual results: when do relative color and color-mix produce different results, and which looks better?
- light-dark() complete theme: Implement a card component that uses only
light-dark() calls for every color property: background, text, subtext, border, and a coloured tag badge. Set color-scheme: light dark on :root. Then create a .theme-override class that sets color-scheme: dark on a container, forcing dark values on a card inside it even when the OS is in light mode. Verify no @media blocks were needed.
- Wide-gamut progressive enhancement: Define a set of semantic color tokens at three levels: sRGB-safe (C ≤ 0.18), P3-enhanced (C = 0.28), and a descriptive comment for Rec.2020 (C = 0.35). Implement the sRGB base in
:root, override with P3 values inside @media (color-gamut: p3). Open the result in a browser on an Apple Display and confirm the P3 overrides take effect using DevTools.
Next: Chapter 8 — CSS Houdini.
The CSS Houdini APIs: CSS.paintWorklet and the Paint API (custom
backgrounds, borders, and highlights without SVG), the Typed Object Model
(element.attributeStyleMap), CSS.registerProperty()
for animatable custom properties, the Properties and Values API, and a
practical custom checkered paint worklet.