Chapters

Part 1 — Foundations · Chapter 7

Mixing and interpolation

linear-gradient(blue, yellow) puts gray concrete in the middle, and nobody wrote it there. Every in-between color is arithmetic in some space — which space owns which mixing job, the hue-path choices polar spaces add, and the CSS defaults that quietly changed.

You write a gradient from blue to yellow. What renders has a band of gray concrete in the middle. You chose two colors; the gray isn't either of them, and it isn't in your CSS. Something put it there.

linear-gradient(rgb(0 0 255), rgb(255 255 0))
linear-gradient(in oklab, rgb(0 0 255), rgb(255 255 0))
Both rows are rendered by your browser, live — same two stops. The top row is the web's default; its center pixel is #808080, chapter 3's value-ladder halfway gray. Add two words and the center becomes #6cabc7, an actual color. Neither middle appears anywhere in the CSS. Something chose it.

That something is the interpolation space — the coordinate system a gradient, a color-mix(), or a transition does its arithmetic in. Every chapter so far placed single colors: what a coordinate means, which ladder it lives on, which knobs are honest, where the screen ends. Two of them ended with the same IOU — chapter 5 said knowing where colors live "doesn't yet say how to travel between them," and chapter 6 warned that a path between two showable colors "can still swing outside on the way." Both come due now.

Midpoints all the way down

Strip the syntax and every mixing feature is one operation repeated. To color the pixel halfway along a gradient, average each coordinate of the endpoints; a quarter of the way, weight the average three to one. color-mix() is a single midpoint; a gradient is midpoints all the way down; a transition is midpoints spread over time. Two endpoints pin down where the path starts and ends — nothing else. The space decides the route, and the route is the gradient.

So the entire question is chapter 3's question wearing new clothes: average on which ladder? Take the opening gradient. Blue is rgb(0 0 255), yellow is rgb(255 255 0) — opposite corners of the sRGB cube, and the straight line between opposite corners passes dead through the cube's gray center. That's the dead zone, and chapter 3's two grays both show up to claim it:

average the values — gamma sRGB#808080 · L* 53
average the light — srgb-linear#bcbcbc · L* 76
average the perception — OKLab#6cabc7 · L* 67
The endpoints never move; only the ladder the average runs on does. The value ladder lands on #808080 — chapter 3's 128 gray, L* 53 but drained of all color: the gray hole in the middle of every naive blue-to-yellow gradient. The light ladder lands on #bcbcbc — chapter 3's half-light gray: the physically honest mix (squint at a fine blue-and-yellow checkerboard and this is what fuses), which reads about three-quarters of the way to white, not halfway. OKLab's midpoint is the one your eye files as "between" — L* 67, believably mid. Notice what it still is, though: barely a color.

The value ladder's answer is #808080 — averaging encoded values systematically darkens and desaturates, and between two vivid complements it doesn't just dull, it dies. The light ladder's answer, #bcbcbc, is physically correct: photons add, and this is genuinely what half blue's light plus half yellow's light amounts to — if the job is simulating light (blur, glow, blending lights), this is the right route, and chapter 3 already filed those jobs under linear. But as a midpoint it fails perception the other way: half the light reads as three-quarters bright. OKLab's answer is the one your eye accepts — believable mid lightness, evenly paced steps on either side. Chapter 5's uniformity promise, applied to travel instead of a ramp.

The lab

Stop taking midpoints on faith — watch whole paths, with instruments.

PlaygroundSame two endpoints — who picks the middle?
Endpoints
OKLCH hue path
sRGB — gamma valuesmid #808080 · C 0.000
Linear sRGB — lightmid #bcbcbc · C 0.000
OKLab — perceptualmid #6cabc7 · C 0.077 · 17% out of sRGB
OKLCH — perceptual, polar (shorter hue)mid #00cfbd · C 0.262 · 73% out of sRGB

Chapter 5's two instruments under each gradient: its light as luminance-matched grays (is the pacing even, or does the middle sag dark or balloon bright?), and its hue path re-rendered at one fixed lightness and chroma (does the walk stay on course? it turns gray where the path is literally hueless). The dark ticks are new: they mark where a path asked for colors outside sRGB — your screen is clipping those pixels, which is the next section's subject.

What to hunt for, preset by preset:

  • Blue → Yellow — the sRGB row's hue strip goes literally gray at the center: the dead zone isn't dim color, it's no color. OKLab's twins are metronome-even, but its own chroma sags — C 0.077 at the midpoint, bottoming out at 0.056 just past it — because OKLab's a and b are opponent axes, distant hues sit on opposite sides of gray, and a straight chord between them cuts inside the wheel. OKLCH holds the vividness — and collects tick marks, because the vivid road asks for colors your screen doesn't have.
  • Red → Green — the mud. Watch the sRGB twins dip darker than both ends.
  • Black → White — no hue to steer, so it's a pure pacing test, and it's chapter 3 verbatim: linear's "midpoint" is the gray that looks three-quarters of the way to white, while sRGB's 128 and OKLab's answer bracket the ~119 most people picked in the midpoint hunt.
  • Two blues — the control group. Every row except linear agrees to within ΔEOK 0.008, well under chapter 6's just-noticeable 0.02.

Interpolation space matters in proportion to the distance between endpoints: between neighbors, any perceptual-ish space is fine; between strangers, the space is the design.

Polar roads: picking the arc

OKLCH's hue is an angle, and between two angles there are always two arcs. Sometimes OKLab's quiet, neutral crossing is exactly what a design wants; when the mix should stay a color the whole way, you want the road around gray, and an arc raises a question the chord never asked: which way around? CSS names four policies, and culori implements the same four:

in oklch shorter huemid hue 60°
in oklch longer huemid hue 240°
in oklch increasing huemid hue 60°
in oklch decreasing huemid hue 240°
The spec's own example pair: a red at hue 30° and a yellow at 90°. shorter takes the 60° arc through orange; longer takes the other 300°, through blue and green. The last two don't measure arcs at all — they impose a direction: the angle may only rise, or only fall. Here increasing lands on shorter's road and decreasing on longer's, but that's this pair's accident, not a rule — flip the endpoints and they trade places, while shorter and longer stay put.

shorter — the default everywhere — takes the smaller arc. longer takes the scenic route. increasing and decreasing don't compare arc lengths at all; they fix a direction of travel. They exist for animation: if an endpoint's hue animates across the 180° mark, shorter snaps to the other arc mid-animation, while a fixed direction never flips. (The demo uses the spec's own red→yellow example pair; the blue→yellow arcs live back in the lab — flip its hue-path control and watch shorter run down through teal and green while longer climbs through magenta and fire, a hot pink at halfway.) Same two endpoints, whole different countries in between.

Here's the polar trade in one sentence: rectangular OKLab can never overshoot vividness but sags toward gray between distant hues; polar OKLCH holds vividness but must choose a route — and vivid routes have tolls.

The door, revisited

The toll is chapter 6's door. Both #0000ff and #ffff00 are plain sRGB colors — in gamut on every screen ever made. The OKLCH path between them holds interpolated chroma through hues where sRGB's tent is low: near hue 229° it asks for C 0.29 where the ceiling is 0.112 — two and a half times the chroma that exists. The ticks in the lab already counted it: 73% of the path is off the map. This isn't an OKLCH quirk, it's geometry: the sRGB gamut is a convex cube only in sRGB's own coordinates — gamma or linear — so straight lines drawn there can never leave it. No perceptual space makes that promise (check the lab: the two sRGB-coordinate rows never collect a single tick). Even OKLab's sagging chord pokes outside near the saturated blue end.

you wrote in oklch — browser renders
per-channel clip
CSS gamut map
Both endpoints are plain sRGB colors — every screen shows them exactly. The road between them, taken in OKLCH, spends 73% of its length outside sRGB (the ticks). Chapter 6's door, met per pixel: the browser rescues each one with per-channel clip, so on an sRGB screen the top row and the clip row match exactly — watch the clip row's hue strip wobble off course where the overshoot is worst. The gamut-map row is the spec's rescue: hue held straight, chroma quietly drained. On a P3 screen the top row stays more vivid through the middle — a wider tent, the same door.

You know the rescue from chapter 6, and it hasn't improved: browsers clip per channel, per pixel, on the way to the glass — the method the spec calls least acceptable, applied mid-gradient where the overshoot is biggest. The engine conclusion is chapter 6's, extended from points to paths: never emit a path and hope. Gamut-check the path, not the endpoints — if a gradient's road leaves the gamut, either pick a calmer road at generation time or gamut-map the stops yourself.

What CSS actually does

The syntax, where CSS gives you the choice:

background: linear-gradient(in oklab, #0000ff, #ffff00);
background: linear-gradient(in oklch longer hue, #0000ff, #ffff00);
color: color-mix(in oklch, var(--brand) 70%, black);

The defaults are where precision pays, because the two features disagree. CSS Color 4 sets the baseline: when nothing says otherwise, interpolation defaults to OKLab — but user agents must interpolate between legacy sRGB color formats (hex, named colors, rgb(), hsl(), hwb()) in gamma-encoded sRGB, so that two decades of existing gradients keep rendering as designed. The spec is candid that this is the compatibility pick, kept "even though it produces poorer results (overly dark or greyish mixes)." Gradients inherit exactly that pair of rules. color-mix() — a new function with no legacy content to protect — gets no carve-out: CSS Color 5 says plainly, "If no color interpolation method is specified, assume Oklab," whatever notation the colors arrive in. So linear-gradient(blue, yellow) makes the gray dead zone while color-mix(in oklab)-style mixing never did — same two colors, different rules. Transitions and animations offer no in syntax at all: by spec, a transition between two hex values runs through gamma sRGB with no way to opt out.

And the gradient rule reads syntax, not values — a gradient's geometry shouldn't change because a color got reserialized, but it does:

linear-gradient(rgb(0 0 255), rgb(255 255 0))legacy stops → srgb
linear-gradient(color(srgb 0 0 1), rgb(255 255 0))one modern stop → oklab
linear-gradient(in oklab, rgb(0 0 255), rgb(255 255 0))explicit — what an engine should emit
Your browser rendering three gradients between the same two colors — color(srgb 0 0 1) is rgb(0 0 255), exactly, in modern spelling. That spelling is the only difference between the first two rows, and it moves the whole gradient from sRGB to OKLab: the legacy carve-out reads the stops' syntax, not their values. The third row says what it means.

One practical note: the in clauses shipped across engines between late 2022 (Safari 16.2) and mid-2024 (Firefox 127), and omitting the method in color-mix() is newer spec text than the function itself — one more reason to write the space explicitly, always, which is where the engine ends up anyway.

In code, culori's interpolate() mirrors the CSS machinery — with one trap: its own default is 'rgb', the legacy web, not the new CSS default.

import { fixupHueLonger, interpolate } from 'culori'

interpolate(['#0000ff', '#ffff00']) // mode omitted: gamma sRGB — the gray middle
interpolate(['#0000ff', '#ffff00'], 'oklab')(0.5) // the believable midpoint
interpolate(['#0000ff', '#ffff00'], 'oklch') // polar, shorter arc, like CSS
interpolate(['#0000ff', '#ffff00'], 'oklch', {
  h: { fixup: fixupHueLonger }, // in oklch longer hue
})

Every computed strip in this chapter is drawn by these calls — the rest are your browser's own gradients. Nothing is traced from a diagram.

One job per space

The spec's interpolation section opens with its own version of this list, and it matches everything the lab showed. Each space is the right answer for some job:

  • Linear light (srgb-linear) — simulating light: blurs, glows, soft shadows, image blends, anything where photons add. This is chapter 3's checkerboard world, and here linear isn't "too light" — it's the only correct answer.
  • OKLab — midpoints for eyes: gradients that pace evenly, mixes that look halfway. The space CSS chose as its modern default, for exactly this reason.
  • OKLCH — staying vivid and steering: when graying out is the failure mode and you want to choose the route around the wheel. Pay the gamut toll deliberately.
  • sRGB — compatibility: matching the web's accumulated look, plus a guarantee neither perceptual space offers — the route can never leave the screen's gamut (linear sRGB shares that guarantee; only gamma sRGB also matches legacy content). Chapter 3 explains why a quarter century of it was tolerable: gamma values are a rough perceptual scale — rough enough to ship, wrong enough to make mud.

There is no single right space to mix in. The space you pick is the question you're asking: what would these lights sum to? versus what looks halfway? versus what stays a color the whole way? A mix is only wrong when the space answers a different question than the one the design asked.

The decision this unlocks

Three, stacked from core to output.

The engine derives every in-between color in OKLab or OKLCH. Hover and pressed states (chapter 18) are interpolations — a nudge toward black, white, or transparent — and doing that mix in the wrong space is how one button's hover goes muddy while another's goes invisible. This also means the engine computes state colors itself rather than leaving them to a CSS transition between hex values, which the legacy carve-out routes through gamma sRGB.

Emitted gradients always name their space. A generated theme can't control whether its stops end up in legacy or modern notation in the user's codebase — and you just watched that spelling silently flip the interpolation space. Explicit in oklab / in oklch shorter hue in everything the engine writes (chapter 20 owns the shipping format).

Ramp steps are not interpolations. Chapter 5's lineup already used interpolate(seed → white) as a test instrument, and even in the right space the result was an even staircase — not a designed scale. Interpolation answers "what lies between A and B" — one question, one route. A ramp answers ten different questions, one per step, each with its own lightness, chroma, and hue targets (chapters 11–13). Naive interpolation is the baseline every ramp philosophy in chapter 11 exists to beat.

Before you move on

Further reading