Why your dark background looks blotchy

Those faint rings in your gradient are not a rendering bug. They are 8-bit quantisation, and the fix is about three percent of noise.

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

You build a subtle gradient — a dark blue fading to a slightly darker blue across a hero section. On your laptop it looks fine. On a colleague's monitor it has visible rings, like a contour map.

That is banding, and it is not a bug in anything.

Where it comes from#

Each channel of an 8-bit color has 256 possible values. A gradient that spans 800 pixels but only twelve steps of lightness has to hold each step for about 67 pixels before it can move to the next one. The transitions are hard edges, and your visual system finds hard edges whether or not you want it to.

It gets worse: Mach banding, an effect where the visual system exaggerates any discontinuity in the rate of change. So the edges are not merely visible — the eye actively enhances them, adding a bright line on one side and a dark line on the other that are not in the data at all.

Dark backgrounds band worst. sRGB's gamma encoding spends most of its precision at the bright end, so the steps between adjacent dark values are perceptually larger. A gradient from #0b1220 to #16213a has very few distinct values available to it.

The fix#

Add noise. Three to five percent, and the banding disappears completely.

This is dithering: adding a small random offset per pixel so the transition between quantisation steps happens at scattered positions rather than along a straight line. The eye averages the noise out and sees a smooth ramp.

Film and print have had this for free forever — grain and halftone dots are dither. It is part of why analogue media look richer than a flat digital gradient.

.hero {
  position: relative;
  background: linear-gradient(in oklab 160deg, #0b1220, #16213a);
}

.hero::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0.04;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}

An inline SVG filter, no image file, no extra request. feTurbulence is rasterised once and tiled, so the runtime cost is effectively zero — it is not recomputed on scroll.

The parameters that matter#

baseFrequency controls grain size. Around 0.8 gives fine film grain. Below 0.3 you get visible clouds, which is a different effect — useful for texture, not for de-banding.

numOctaves adds layers of detail. Three is plenty; more is more expensive to rasterise for a difference nobody sees.

opacity is the one to be careful with. Under 0.08 it reads as smoothing — people perceive the gradient as cleaner without noticing why. Above that it reads as a deliberate grain effect, which is a legitimate choice but a different one.

typefractalNoise gives even, film-like grain. turbulence gives a cloudier, more organic result.

Where else it shows up#

Large flat dark fills. Not just gradients. A full-viewport #0d0d0f background can show blotching on cheaper panels, because the display's own gamma correction quantises further.

Blurred backdrops. backdrop-filter: blur() produces smooth gradients from whatever is behind it, and those band exactly the same way.

Shadows. A large soft shadow is a gradient. On a dark background, a 60px blur can show rings.

Mesh gradients. Multiple overlapping radial gradients means multiple opportunities to band, and they compound.

The alternative that does not work#

The instinct is to add more gradient stops. It does not help — the stops still land on the same 256 available values. You cannot subdivide below the quantisation step; you can only break up where the step boundaries fall, which is what dithering does.

The other thing that does not help is switching to a wide-gamut color space. Display P3 has more colors but the same 8-bit-per-channel encoding in most contexts. It moves the problem, it does not remove it.

Higher bit depth genuinely fixes it, and you do not control whether the user has it.

Three percent noise, on the other hand, works everywhere.