Grainy gradients and noise texture: how to actually build them
The noise comes from feTurbulence
Almost every grain effect on the web starts with the SVG filter primitive feTurbulence, which generates Perlin noise straight into the red, green, blue and alpha channels. It takes no source image — it is a generator, so you point it at an empty rectangle and it fills it.
type picks between turbulence and fractalNoise. Turbulence sums the absolute value of each noise layer, so values pile up near zero with sharp ridges where the sign flips: wispy, veined, marble-like. fractalNoise sums the signed values, centring everything around the midpoint and producing the soft, even cloud that reads as grain — nearly always the right pick for texture.
baseFrequency is the size of one noise cell in cycles per user unit, so it works as an inverse scale: 0.6–0.9 gives pixel-level speckle, the classic film-grain look, while 0.02 gives slow, wide clouds. numOctaves is how many layers get summed, each at double the frequency and half the amplitude of the last — three or four stack fine detail on coarse and look organic, while past four the extra layers cost a full pass over the pixels and sit below what you can see. seed fixes the random sequence, and stitchTiles="stitch" nudges the frequency to a whole number of periods so the result can repeat without a seam.
One surprise catches everyone once: the alpha channel gets noise too, so a raw feTurbulence rectangle renders as translucent coloured confetti rather than grey grain. Chain <feColorMatrix type="saturate" values="0"/> after it to strip the colour.
Getting it onto the page
filter: url(#grain) on a live element re-runs the filter on every repaint and applies to the element’s children, text included — wrong tool for a background. What nearly everyone ships instead is a pseudo-element carrying the whole SVG as a data URI:
.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'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
Now the SVG is just an image: rasterised once, cached, free on repaint, unable to touch the text underneath, and a few hundred bytes. Use single quotes inside the SVG so the outer url("…") never collides with them, percent-encode rather than base64, and remember # must become %23 — hex colours and the url(#n) reference are the first things to break when you hand-edit one. Leaving width and height off the <svg> lets it stretch to fill the background area, so one continuous field covers the box with no repeat at all.
PNG tiles buy certainty and pay in bytes
A PNG of noise has one genuine advantage: it is exact. The same file looks identical in every browser and drops into Figma or Photoshop as a layer, while feTurbulence output varies slightly between rendering engines. The cost is size — PNG compresses by predicting each pixel from its neighbours, and random noise has no predictable neighbours by definition, so a 512×512 greyscale noise PNG runs into the hundreds of kilobytes where the SVG equivalent is a few hundred bytes.
Tiling is the other catch. A random tile’s opposite edges have no reason to agree; with pixel-level grain nobody notices, but with coarse grain a distinctive cluster reappears on a fixed grid, and once seen it cannot be unseen. Coarse grain wants one large texture used once; fine grain tiles happily. And a noise PNG only really works at 1:1, since any other scale blurs the specks or averages them away.
Why grain fixes gradient banding
Banding is a quantisation artefact. Eight bits give 256 steps per channel, so a gradient shifting thirty levels across 1200 pixels can only change value every forty pixels or so — and the eye’s edge response turns each step into a visible stripe.
Noise fixes it by dithering: a random value of one or two levels makes pixels near a step boundary fall on either side of it at random, scattering the contour into a transition with no edge left to detect. The important part is how little you need. Anti-banding grain should be invisible, somewhere around 2–6% opacity; if you can see it, you have gone past the fix and into texture, which is a fine choice but a separate one.
Strength: opacity first, blend mode second
opacity on the overlay is the honest dial, and 0.1–0.25 covers most grainy gradients. mix-blend-mode changes character rather than amount: overlay leaves mid-grey untouched and pushes lights and darks apart while preserving hue, soft-light is its gentler version, multiply only darkens so grain settles into the shadows like print on paper, and screen only lightens so it sits in the highlights like film or dust.
The classic trap is that mix-blend-mode blends against the backdrop within the nearest stacking context, and a parent with its own opacity, transform, filter, or isolation: isolate becomes that context — the grain then blends against nothing and flattens to grey. When the gradient and the noise are two background layers on one element, background-blend-mode sidesteps it. Performance follows the same logic: keep noise in a background image rather than a live filter, keep pointer-events: none on the overlay, and do not animate it.
Where grain quietly breaks
High-DPI screens flatten it. CSS pixels are not device pixels, so a 1× noise PNG on a 2× display draws each speck as a 2×2 block, chunkier and softer than intended. Export at 2× and halve it with background-size instead, and the downscale averages neighbouring light and dark pixels together, collapsing contrast toward flat grey until the texture all but disappears. That is the “my grain vanished on retina” complaint, and it is arithmetic rather than a bug. feTurbulence avoids part of it, since its frequency is in user units and the pattern keeps its size in CSS pixels — but it still does not read identically at 1× and 2×, so tune on the densest screen you support and then check it at 1×.
Dark mode needs a different number. Grain pushes values both ways around a midpoint, but on a light background the dark specks do the visible work while on a dark one the light specks do — and a light speck on near-black is far more conspicuous. The same opacity that whispers in light mode reads as static in dark mode, so cutting it by a third to a half is a normal correction. Dark interfaces also band the worst, since near-black gradients span very few levels: more dither and less visible texture, at once. Keep the strength in a custom property and override it in the dark block.
Contrast is the last trap. Ratios are calculated between two flat colours, so a textured background makes the real ratio vary pixel by pixel and the worst local case is the one that counts. Keep grain mild anywhere text sits on it, or put the text on a flat panel above the grainy background.
Which output fits where
Our noise texture generator covers both routes. Its noise tab gives you fine grain — a fresh random value per pixel — or smooth noise, fractal value noise across three octaves with a grain size from 4 to 256 px, plus an intensity slider that stretches contrast around mid-grey, a monochrome toggle and a tint colour. Transparent mode pins RGB to the tint and lets the noise drive alpha, which is what you want when the texture goes on its own layer in Figma or Photoshop with the blend mode set there. The gradient tab builds a linear or radial gradient with start, end and an optional middle stop, then composites grain over it in overlay. Canvases run from 16 to 4096 px per side on both tabs.
Both tabs export two ways, and the choice maps onto everything above. Download PNG gives the exact pixels at full resolution, right for a design file or anywhere the result has to look identical everywhere. Copy CSS snippet gives an feTurbulence data URI instead, plus background-blend-mode: overlay on the gradient tab, which suits a live site where you would rather not ship an image at all — but it is a look-alike, not an export, since the SVG filter and the tool’s noise engine are different algorithms.
The rest of the limits, plainly: the PNG is a single texture at the size you choose, not a seamless tile, so use it at full size rather than repeating it; Regenerate reshuffles the seed but you cannot type one in; and there is no animation or per-axis frequency control. Everything renders on your own device and there is no upload at any point, which for a generated texture matters less for privacy than for the fact that nothing has to travel anywhere before you see the result.