Draw a gradient from blue to yellow in CSS and the middle turns grey. Not a greenish grey. Actual grey, #808080.
Neither colour has any grey in it, and the result still looks dirty. Try it below with a few pairs.
The top strip is what linear-gradient(blue, yellow) gives you. The bottom two are what you probably wanted.
A gradient is an average
A browser doesn't know what colours are. To draw a gradient it takes the numbers for the two ends and, at each point along the way, mixes them in proportion. A quarter of the way along is 75% of the first set of numbers plus 25% of the second.
So the only real question is which numbers.
By default, for colours written as hex, rgb() or names, the browser averages the red, green and blue values you wrote. Blue is 0, 0, 255. Yellow is 255, 255, 0. Halfway between them is 127.5, 127.5, 127.5.
That's grey. The maths isn't wrong, it's just averaging the wrong thing.
The numbers aren't light
There are two separate problems in that grey. The first is that it's too dark.
An sRGB value isn't an amount of light. It's gamma encoded: the stored numbers are bent along a curve, so more of them describe dark shades, where our eyes are most sensitive. The sRGB standard turns a stored value into light with a small linear piece near black and roughly a power of 2.4 everywhere else.
Averaging along that curve sags toward dark. The fix that physics suggests is to undo the curve first, average the actual light, then encode the result again. CSS calls that space srgb-linear.
It's the second strip in the demo. Blue to yellow is still grey there, but now it's a bright grey, because the red, green and blue lights really do add up to white-ish. And with black to white, linear light overshoots: the middle looks too pale. Light adds linearly, but our sense of brightness doesn't.
Measure what people see
What you actually want is a space where equal steps in the numbers look like equal steps to a person. That's called perceptually uniform.
OKLab, published by Björn Ottosson in December 2020, is a small, fast model built for exactly this. It has three numbers: L for perceived lightness, and a and b for how green or red and how blue or yellow a colour is. The conversion from linear sRGB is two 3×3 matrices with a cube root between them.
function linearSrgbToOklab([r, g, b]) {
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, // L
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, // a
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, // b
];
}
The first matrix roughly models the three kinds of cone in your eye. The cube root compresses them the way perception does. The second matrix rotates the result so lightness and the two colour axes come out separate.
Average in OKLab and the midpoint's lightness lands exactly halfway between the ends, because L is the thing being averaged. For blue to yellow that's 0.71, where sRGB gave 0.60 and linear gave 0.79.
Why OKLab still goes a bit grey
Look at the OKLab strip again for blue to yellow. It's lighter and cleaner, but the middle is still a pale, washed-out colour.
That's the second problem, and it's geometry. Blue and yellow sit on opposite sides of the colour plane. Draw a straight line between them and it runs past the middle, and the middle of that plane is grey.
OKLCH is the same space described differently. Instead of a and b it uses chroma (distance from grey) and hue (angle around the centre). Interpolate those and the path becomes an arc. It keeps its distance from grey, so the middle stays colourful.
The cost is that you now have to pick a direction around the circle. shorter hue is the default and takes the smaller arc. longer hue goes the long way, which is how you get a rainbow out of two colours. Blue to yellow the short way passes through teal and green.
Writing it in CSS
You choose the space with an interpolation method, right after the direction.
.a { background: linear-gradient(to right, blue, yellow); } /* sRGB */
.b { background: linear-gradient(to right in oklab, blue, yellow); }
.c { background: linear-gradient(to right in oklch, blue, yellow); }
.d { background: linear-gradient(to right in oklch longer hue, blue, yellow); }
.e { color: color-mix(in oklab, blue, yellow); }
CSS Color 4 makes OKLab the default interpolation space, with one exception: when every colour is in a legacy sRGB format (hex, rgb(), hsl(), names), it stays in sRGB "for Web compatibility". So the old muddy gradient is still what you get unless you ask. Write even one end as oklch(…) and the default quietly flips to OKLab, which is one more reason to say in oklab out loud.
Support is broad. Interpolation methods in gradients shipped in Chrome 111, Safari 16.2 and Firefox 127, and color-mix() in Chrome 111, Safari 16.2 and Firefox 113. A browser without it will drop the whole declaration, so put a plain gradient before it as a fallback.
The part that's easy to miss
OKLCH keeps chroma up. Sometimes that's more chroma than your screen can show.
The blue-to-yellow arc passes through a teal with chroma around 0.26 at lightness 0.71. No sRGB colour is that vivid at that lightness. The red marks under the OKLCH strip in the first demo show where that happens.
The spec says interpolated values are not clipped during interpolation, only when they're finally displayed. It describes a gamut-mapping method that lowers chroma while holding lightness and hue, which is what this demo does. Browsers have mostly clipped each channel instead, which can bend the hue. On a wide-gamut display, some of those colours fit in Display P3 and show as they are.
So a teal in your OKLCH gradient can look different on two screens. If that matters, keep the ends at a chroma both can show, or add a stop in the middle that you've picked yourself.
The bigger point is that a gradient never had a colour of its own. It's two colours and a decision about what "halfway" means. sRGB's answer was an accident of how numbers are stored. For most interfaces, in oklab is the better default, and in oklch is for when you want the colour to travel.