A gradient is a continuous mathematical idea being drawn on a device with a finite number of colours. Somewhere between the two, the continuum has to be rounded — and when the rounding is coarse relative to the distance, you can see where it happened.
Where the steps come from
An ordinary screen gives each channel 256 values. A gradient from one dark grey to another only slightly different might have twenty distinct values available to it, and if it is spread across a thousand pixels, each of those twenty occupies a fifty-pixel stripe.
Your eye is extremely good at finding edges, and a fifty-pixel band of flat colour against another is an edge. This is Mach banding — the visual system exaggerates the boundary, which is why the stripes look sharper than the numbers suggest.
So banding gets worse with a longer gradient, a smaller colour difference, and darker colours — where the available values are spaced further apart perceptually.
Why it looks fine in the design tool
Design tools work internally at higher precision and often dither their preview. The browser draws what the CSS says at whatever precision the compositor uses, with no dithering.
Which is why this is discovered after the handoff, and why the designer is genuinely not seeing it.
The grey middle is a different fault
Worth separating, because the fix is unrelated.
A gradient from blue to yellow in sRGB blends the red, green and blue channels independently. Halfway between them every channel sits at a middling value, and a middling value in all three is grey. The gradient passes through a desaturated centre that neither end colour suggested.
The fix is to interpolate somewhere else. CSS accepts an interpolation space — requesting OKLCH takes the path your eye expects, keeping the chroma up as the hue rotates. It is the same reason OKLCH produces even palettes where HSL does not.
Transparent is not what you think
transparent in CSS means transparent black.
So a fade from white to transparent travels through translucent black and picks up a grey cast in the middle. This is one of the most common causes of a dirty-looking overlay and it looks exactly like a banding problem.
Fade to the same colour at zero alpha instead — rgb(255 255 255 / 0) — and the murk disappears.
Four fixes, in order of effort
Add colour stops. Two or three intermediate stops shorten each run and give the renderer more anchors. Often enough on its own, and free.
Shorten the gradient or increase the difference between the ends. The problem is a function of both, so either helps.
Add noise. The standard answer, borrowed from print and film. A faint random texture over the gradient breaks up the flat bands so no clean edge remains. Two or three per cent opacity is invisible as texture and completely removes the stripes. An inline SVG turbulence filter or a small tiled PNG both work and cost almost nothing.
Change the interpolation space, which fixes the grey middle rather than the banding — but a gradient that keeps its chroma has larger steps between neighbouring values, which incidentally makes the banding less visible too.
Checking it properly
Look on a real device rather than a good monitor, in a dark room. Banding hides in ambient light and appears the moment someone uses the site in bed, which is a large share of the time.
And build the gradient at the size it will actually be. A swatch never bands; a hero section does, and they are the same colours.
A generator that shows the result at full width, with the stops adjustable, is the fastest way to find the number of stops that fixes it.
The short version
Banding is rounding made visible, and it worsens with length, with similarity and with darkness. Add stops, then add noise. And if the middle is grey rather than striped, that is the interpolation space or the transparent keyword — neither of which more stops will fix.