Developer

Gradients and palettes: why yours goes grey in the middle

Blend blue into yellow and you get a muddy grey halfway. That is not your eye, it is the colour space doing arithmetic that does not match perception.

4 minute read

You build a gradient from a strong blue to a warm yellow. The ends look right and the middle is a dull grey-green that belongs to neither. Adding more stops does not fix it, and picking different blues does not either.

The cause is that the browser is doing arithmetic in the wrong space.

Why the middle goes grey

A CSS gradient interpolates between two colours. By default it does this in sRGB, averaging the red, green and blue channels independently.

Blue is roughly rgb(0, 80, 220) and yellow is roughly rgb(250, 210, 0). Average them channel by channel and you get rgb(125, 145, 110), which is a muted olive. Arithmetically correct, perceptually a dead spot.

The problem is that sRGB is a description of what a monitor does, not of how vision works. Equal steps in sRGB are not equal steps in perceived colour, and the straight line between two saturated colours passes near the desaturated centre of the space.

Perceptual colour spaces are built to fix this. OKLCH describes a colour as lightness, chroma (saturation) and hue. Interpolating in it moves the hue around the colour wheel while holding chroma, so the midpoint stays saturated:

background: linear-gradient(in oklch, #0050dc, #fad200);

That one in oklch is usually the entire fix. Modern browsers support it, and the fallback in older ones is the sRGB behaviour you already have, so it degrades harmlessly.

A gradient generator is worth using precisely because this is a case where looking at the result beats reasoning about it. Two gradients with identical endpoints can look completely different depending on the interpolation, and no amount of thinking about hex values tells you that.

Banding, and the other reason gradients look cheap

The second common complaint is visible stripes across a large gradient.

A gradient across 1000 pixels from one colour to another that differs by 40 steps in a channel has to hold each step for 25 pixels. The eye is very good at spotting those edges, especially in dark areas and in smooth blue-to-black fades.

Three things help. Keep the endpoints closer in lightness, so there are fewer steps to spread. Add a very slight noise texture over the top, which is what design software does when it dithers. Or use a subtle multi-stop gradient rather than a two-stop one, which spreads the transition unevenly and hides the boundaries.

Harmony rules are a starting point, not a palette

Palette generators offer complementary, analogous, triadic and so on. These are hue relationships: complementary is 180 degrees around the wheel, triadic is three colours 120 degrees apart, analogous is a few neighbouring hues.

They are useful for generating candidates. They do not produce a usable palette on their own, because hue is only one of three dimensions and the other two matter more.

The trap is deriving a palette by rotating hue and keeping lightness and saturation fixed. Do that and you get a set of colours that are mathematically related and visually unbalanced, because perceived brightness varies enormously by hue. Yellow at 60% lightness looks far brighter than blue at 60% lightness. Pure yellow text is unreadable on white while pure blue is fine, at the same nominal lightness.

So the working process is:

  1. Generate candidates with a palette generator to get the hue relationships.
  2. Adjust each colour's lightness individually until they look balanced, not until the numbers match.
  3. Check every foreground and background pair you intend to use with a contrast checker.
  4. Convert to whatever format your codebase uses with a colour converter.

Step two is the one that separates a palette that works from a set of related hex codes.

Build the scale, not the colours

One practical structure that survives contact with a real interface: for each hue you keep, generate a scale of about nine steps from very light to very dark, and use positions rather than individual colours in your CSS.

That way --blue-100 is always a background tint and --blue-700 is always readable text, across every hue in the system. Swapping the brand colour becomes changing one hue value rather than auditing every usage.

Working in OKLCH makes this far easier, because holding lightness constant across hues actually produces colours that look equally light, which is the thing sRGB cannot give you.

Build the gradient in the gradient builder, add a midpoint stop if the middle goes grey, and check any colour you place text on with the contrast checker before it reaches a real screen.

Further reading

Do it hereGradient GeneratorBuild CSS linear and radial gradients visually and copy the CSS codeOpen

Written for NextTool. Every statement about a tool is checked against that tool's code, and corrections are dated above. How guides are written

colourgradientscss