The muddy middle: why your gradients go gray

Blue to yellow should pass through green. In CSS it passes through gray. The cause is one line in the spec and the fix is one keyword.

↑ Click any of these to repaint the entire site with it.

Put a gradient from blue to yellow on a page. Look at the middle. It is gray.

Not a greenish gray — a genuine, chroma-free, dishwater gray. The two colors you chose are both intensely saturated, and the transition between them passes through a band that has no color in it at all.

This is not a browser bug and it is not a display limitation. It is what happens when you average two gamma-encoded numbers.

Why it happens#

sRGB values are gamma-encoded: the number 128 does not mean "half the light of 255", it means roughly 21% of the light, because the encoding devotes more precision to the dark end where the eye is more sensitive.

A gradient between two colors in sRGB interpolates the encoded numbers directly. Halfway between blue (0, 0, 255) and yellow (255, 255, 0) is (127.5, 127.5, 127.5). That is gray. The maths is doing exactly what it was told; nobody told it that the numbers were not linear quantities.

The problem is worst between complementary or near-complementary pairs, because those are the pairs whose channel values most nearly cancel. Blue to yellow, red to cyan, purple to green — all of them collapse.

Four spaces, one gradient#

The same two stops, interpolated four ways:

SpaceWhat happens at the midpoint
sRGBChroma collapses. Gray band.
Linear sRGBPhysically correct light mixing. Brighter, still not perceptually even.
OKLabChroma preserved. Even lightness progression.
HSLSweeps around the hue circle, inventing hues in neither stop.

That last one deserves a note. HSL interpolation between blue and yellow travels through the entire circle — green, cyan, or magenta and red depending on direction. Sometimes that rainbow sweep is precisely the effect you want. Usually it is a surprise.

The fix#

One keyword:

/* The gray one */
background: linear-gradient(to right, #0000ff, #ffff00);

/* The good one */
background: linear-gradient(in oklab to right, #0000ff, #ffff00);

in oklab is supported in every current browser. Ship the plain version first as a fallback and the hinted version second — anything that cannot parse the second declaration keeps the first, which is how CSS has always handled this.

.hero {
  background: linear-gradient(to right, #0000ff, #ffff00);
  background: linear-gradient(in oklab to right, #0000ff, #ffff00);
}

When you cannot use the hint#

If you need byte-identical rendering across older engines, or the gradient is going into an environment that does not parse modern color syntax, sample it yourself. Compute a dozen stops in OKLab, emit them as ordinary hex, and let sRGB interpolate between values that are already close enough together that the error is invisible.

background: linear-gradient(to right,
  #0000ff, #4a17c7, #6b2d9e, #85427c, #9b585e,
  #ae6f42, #bf8829, #cda212, #d8bd00, #e0d900, #ffff00);

More verbose, works everywhere.

The same problem, other places#

Interpolation space is not only a gradient concern. Anywhere two colors are blended, the same failure is available:

  • CSS transitions between two colors. A hover transition from a blue to a red passes through the same gray band, for about 200ms. You will not consciously see it, but the transition reads as slightly muddy.
  • color-mix(). It takes the same in <space> argument. Default it to oklab.
  • Generated tints and shades. Mixing your brand color toward white in sRGB produces a chalky, slightly desaturated ramp. In OKLab it stays clean.
  • Image scaling. Downscaling an image averages pixels. Most software does it in gamma-encoded space, which is why bright thin lines on dark backgrounds fade when you shrink an image.

One more thing about gradients#

While you are in there: a two-stop linear gradient has a linear alpha progression, and linear is not how light falls off. A gradient that fades to transparent will show a visible edge partway along, because the human eye picks out the discontinuity in the rate of change.

The fix is more stops with an eased distribution — or, in modern CSS, an interpolation hint:

/* the 35% marks where the midpoint color lands */
background: linear-gradient(in oklab to bottom, #0b1220, 35%, transparent);