Advanced Color — oklch, color-mix(), and Color Spaces
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)
+
Red
oklch(0.62 0.22 25)
=
50% mix
color-mix(in oklch, ... 50%, ...)
/* color-mix(in <color-space>, <color1> [pct], <color2> [pct]) *//* Basic mix — equal halves */color-mix(inoklch,blue 50%,red)/* Asymmetric mix — 30% of first color, 70% of second */color-mix(inoklch,blue 30%,red 70%)/* Single percentage — the remainder is taken from the second color */color-mix(inoklch,blue 30%,red)/* same as above *//* Color spaces: oklch, oklab, srgb, hsl, hwb, lab, lch, display-p3 */color-mix(insrgb,blue,red)/* produces muddy purple */color-mix(inoklch,blue,red)/* travels the hue wheel — cleaner midpoint *//* Hue interpolation methods for oklch and lch */color-mix(inoklch shorter hue,blue,red)/* default — shortest path on hue wheel */color-mix(inoklch longer hue,blue,red)/* longest path — goes through more hues */color-mix(inoklch increasing hue,blue,red)/* always increasing H value *//* Practical: tinting towards white (mix with a very-high-L neutral) */--btn-hover-bg:color-mix(inoklch,var(--brand-blue)80%,white);--btn-active-bg:color-mix(inoklch,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(insrgb,black 60%,transparent);/* Generating a tonal surface from a brand color */:root{--brand:oklch(0.55 0.22 248);--surface-brand:color-mix(inoklch,var(--brand)12%,oklch(0.1 0 0));--border-brand:color-mix(inoklch,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.
sRGB
Standard web colors
100%
display-p3
Apple, modern screens
~150%
Rec.2020
HDR / broadcast
~220%
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(fromvar(--brand) l c h)/* exact copy */oklch(fromvar(--brand)calc(l + 0.15) c h)/* lighter variant */oklch(fromvar(--brand)calc(l - 0.15) c h)/* darker variant */oklch(fromvar(--brand) l calc(c * 0.5) h)/* less saturated */oklch(fromvar(--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(fromvar(--btn-bg)calc(l + 0.08) c h);--btn-active:oklch(fromvar(--btn-bg)calc(l - 0.08) c h);--btn-text:oklch(fromvar(--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(fromrebeccapurple l c h)/* named color → oklch */oklch(from#58a6ff l c h)/* hex → oklch */oklch(fromhsl(220 80% 60%) l c h)/* hsl → oklch *//* alpha channel — available as 'alpha' in all color spaces */oklch(fromvar(--brand) l c h / 0.5)/* 50% transparent */oklch(fromvar(--brand) l c h / alpha)/* preserve original alpha */
Five states derived from one base token: oklch(0.55 0.22 248)
ActiveL-0.20
PressedL-0.08
BaseL=0.55
HoverL+0.08
DisabledC×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(inoklch,blue,yellow)/* Controlling hue travel direction */linear-gradient(inoklch shorter hue,oklch(0.6 0.2 0),oklch(0.6 0.2 200))linear-gradient(inoklch longer hue,oklch(0.6 0.2 0),oklch(0.6 0.2 200))/* Rainbow gradient using hue wheel */.rainbow{background:linear-gradient(inoklch 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 */@keyframespulse{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 */
/* 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(fromvar(--color-accent)calc(l + 0.07) c h);--btn-active:oklch(fromvar(--color-accent)calc(l - 0.07) c h);--btn-ring:oklch(fromvar(--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.