Grain on the gradient
Why a CSS gradient bands, and the inline SVG noise that makes it stop
try it
A CSS gradient looks cheap for a reason that has nothing to do with the colours: it is too perfect. Film grains, print screens, photographs have sensor noise. A gradient has none, and across a wide, slow transition that purity shows up as banding: visible stripes where the colour should be continuous.
The fix is a layer of noise on top, at a low opacity, generated by the browser from an inline SVG.
1. Why the bands are there
A screen gives you 8 bits per channel: 256 steps. Spread a gradient from #0a1020 to #1d4ed8 across 1200 pixels and the blue channel has to cover about 160 steps in 1200 pixels — roughly 7 pixels of identical colour per step. Seven pixels is wide enough for the eye to catch the edge, and Mach bands (the way vision exaggerates edges) make it worse.
It is not a bug in the browser and no gradient syntax fixes it. The colour space genuinely has no value between those two steps.
What does fix it is dithering: scattering a little noise so the boundary between two steps becomes a mix of both instead of a clean line. Printers have done it for a century.
2. The noise, in one declaration
feTurbulence is an SVG filter that generates Perlin noise. Rendered onto a rect and used as a background image, it never touches the network:
.hero::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.15;
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");
}
No image file, no request, no decode: the browser runs the filter and caches the result.
3. The three knobs on feTurbulence
| Attribute | What it does | Useful range |
|---|---|---|
type |
the character of the noise | fractalNoise |
baseFrequency |
grain size — higher means finer | 0.6 – 1.2 |
numOctaves |
layers of detail | 2 – 4 |
type="fractalNoise" is the one you want. The other value, turbulence, produces the smoky, cloudy noise used for fire and water; it clumps, and clumps read as dirt on a gradient. fractalNoise is even, like film grain.
baseFrequency is in units per pixel, so 0.8 gives grain just over a pixel wide. Below 0.4 the noise turns into visible blobs; above 1.5 it disappears into the pixel grid and only costs you render time.
numOctaves adds finer layers on top of the base one. Each octave is another pass of the filter, so 3 is the practical ceiling: 6 looks almost identical and renders several times slower.
stitchTiles="stitch" matters only if the layer tiles; it makes the edges line up instead of showing seams.
4. Escaping the data URI
The SVG goes inside a CSS url(), so two characters have to be encoded or the declaration breaks:
#becomes%23— otherwiseurl(#n)cuts the URI short at the fragment%becomes%25— so100%has to be written100%25
Quotes are the other trap: use single quotes inside the SVG and double quotes around the whole url("…"). And do not base64-encode it. Base64 makes the string a third longer, kills gzip, and the plain text version is the one you can still read and tweak six months later.
5. Opacity, and when to blend
The useful range is 10% to 20%. Below 8% the dithering is too weak to break the bands; above 25% the surface stops looking like a gradient with grain and starts looking like a dirty gradient.
Flat opacity applies the same grain to light and dark areas alike. Real grain is stronger in the midtones, and mix-blend-mode gets closer to that:
.hero::after {
mix-blend-mode: overlay;
opacity: 0.35;
}
overlay lets the noise follow the luminance of what is underneath, so it fades out in the darkest and lightest parts. It needs a higher opacity to read, and it needs the parent to not create an awkward stacking context. soft-light is the gentler version of the same idea.
On a dark background, plain opacity is usually enough, and it is cheaper.
6. What it costs
The filter is rasterised once and then it is a cached bitmap, so scrolling is free. Two things are not free:
- a full-viewport noise layer on a phone is a large texture to generate on first paint — keep it on the element that needs it, not on
body - animating
baseFrequency(the “living grain” effect) re-runs the filter every frame and will drop you to single digit framerates
pointer-events: none is not optional: the layer covers the element with inset: 0 and would otherwise swallow every click underneath it.
7. Final result
.hero {
position: relative;
background: linear-gradient(135deg, #0a1020, #1d4ed8 55%, #4d9dff);
}
.hero::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.15;
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");
}
The two squares above are the same gradient. The one on the right has the layer at 0.14; the bands on the left are the ones your eye was already finding cheap.
✅ Simple rule: Banding is a colour depth problem, and noise is the hundred-year-old answer to it. Fifteen percent is all it takes.