Technique

Grainy gradients

Add film grain to a CSS gradient with a stitched feTurbulence tile at the right opacity: 1–4% removes banding on wide surfaces, 8–15% is the stylistic grain. Live generator, the technique explained, copyable CSS and SVG.

Open in the full maker →
Aa — the quick brown fox
black text · worst 6.47:1 · AA
banding high · 20.28px
2 stops · click the track to add · drag to move · select to edit

Export

CSS with the interpolation space, Tailwind v4, SVG with correct geometry, a DTCG token, and the PNG.

Apply this to a brand → sign in

.gradient {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.802' numOctaves='2' seed='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='3' intercept='-1'/%3E%3CfeFuncG type='linear' slope='3' intercept='-1'/%3E%3CfeFuncB type='linear' slope='3' intercept='-1'/%3E%3C/feComponentTransfer%3E%3CfeColorMatrix type='matrix' values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -12'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.016'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)'/%3E%3C/svg%3E"), linear-gradient(135deg, #ff512f 0%, #fe572e 7.14%, #fd5e2d 14.29%, #fd632c 21.43%, #fc692b 28.57%, #fb6e2a 35.71%, #fa7328 42.86%, #f97827 50%, #f87d26 57.14%, #f78224 64.29%, #f58622 71.43%, #f48b20 78.57%, #f38f1e 85.71%, #f1941c 92.86%, #f09819 100%);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.802' numOctaves='2' seed='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='3' intercept='-1'/%3E%3CfeFuncG type='linear' slope='3' intercept='-1'/%3E%3CfeFuncB type='linear' slope='3' intercept='-1'/%3E%3C/feComponentTransfer%3E%3CfeColorMatrix type='matrix' values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -12'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.016'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)'/%3E%3C/svg%3E"), linear-gradient(135deg in oklab, #ff512f 0%, #f09819 100%);
  background-size: 160px 160px, auto;
  background-blend-mode: soft-light, normal;
}

/* or keep the gradient untouched and overlay the grain: */
.gradient {
  position: relative;
  isolation: isolate;
}
.gradient::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.802' numOctaves='2' seed='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='3' intercept='-1'/%3E%3CfeFuncG type='linear' slope='3' intercept='-1'/%3E%3CfeFuncB type='linear' slope='3' intercept='-1'/%3E%3C/feComponentTransfer%3E%3CfeColorMatrix type='matrix' values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -12'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)'/%3E%3C/svg%3E");
  background-size: 160px 160px;
  image-rendering: pixelated;
  mix-blend-mode: soft-light;
  opacity: 0.0156;
}

How it works

Eight bits per channel is 256 levels. A gradient that moves lightness by 20% across a 1600px hero has about 51 steps to spend over 1600px — a visible band every ~31px. Wide, low-contrast gradients band worst, which is exactly the "subtle brand background" case. More stops do nothing; the renderer still quantises to 8 bits. The actual fix is noise.

Grain versus dither

Dither adds the noise before quantisation (trunc(f + rnd)) so the rounding error averages out — it removes banding, and it needs a shader, a canvas or a pre-baked lossless image. Grain adds noise after (trunc(f) + rnd) — every CSS technique is this — so it masks the step rather than removing it, which is why the anti-banding default is so low: about 1% (≈2.5 LSB peak to peak) hides the band, 2–5% reads as texture, 8–15% is the film-grain look everyone searches for. Blend it with soft-light and the noise fades out automatically at black and white.

The recipe

The canonical source is Jimmy Chion's "Grainy Gradients" on CSS-Tricks: an SVG feTurbulence filter (type fractalNoise, stitchTiles="stitch" — mandatory, or the tile seams) pushed to black-and-white speckle with contrast() / brightness(). Two refinements here: baseFrequency and opacity move together on one slider (at 14: frequency 0.802, opacity 0.0156), and the noise is rasterised once into a 160px data-URI tile instead of running a live SVG filter on every paint. The overlay form keeps the gradient untouched — a ::after at the slider opacity, mix-blend-mode: soft-light.

css · overlay form
.gradient {
  position: relative;
  isolation: isolate;
}
.gradient::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: url("data:image/svg+xml,…");
  background-size: 160px 160px;
  image-rendering: pixelated;
  mix-blend-mode: soft-light;
  opacity: 0.0156;
}

The report on every gradient measures the widest band in pixels at 1440px and, when it is over 4px on a subtle ramp, suggests the grain amount that hides it. That is the number the "apply grain" button in the maker uses.

Starting point, as code

css
.gradient {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.802' numOctaves='2' seed='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='3' intercept='-1'/%3E%3CfeFuncG type='linear' slope='3' intercept='-1'/%3E%3CfeFuncB type='linear' slope='3' intercept='-1'/%3E%3C/feComponentTransfer%3E%3CfeColorMatrix type='matrix' values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -12'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.016'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)'/%3E%3C/svg%3E"), linear-gradient(135deg, #ff512f 0%, #fe572e 7.14%, #fd5e2d 14.29%, #fd632c 21.43%, #fc692b 28.57%, #fb6e2a 35.71%, #fa7328 42.86%, #f97827 50%, #f87d26 57.14%, #f78224 64.29%, #f58622 71.43%, #f48b20 78.57%, #f38f1e 85.71%, #f1941c 92.86%, #f09819 100%);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.802' numOctaves='2' seed='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='3' intercept='-1'/%3E%3CfeFuncG type='linear' slope='3' intercept='-1'/%3E%3CfeFuncB type='linear' slope='3' intercept='-1'/%3E%3C/feComponentTransfer%3E%3CfeColorMatrix type='matrix' values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -12'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.016'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)'/%3E%3C/svg%3E"), linear-gradient(135deg in oklab, #ff512f 0%, #f09819 100%);
  background-size: 160px 160px, auto;
  background-blend-mode: soft-light, normal;
}
tailwind v4
bg-linear-to-br/oklab from-[#ff512f] to-[#f09819]
svg
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="600" viewBox="0 0 1440 600"><defs><linearGradient id="bs-gradient" gradientUnits="userSpaceOnUse" x1="210" y1="-210" x2="1230" y2="810"><stop offset="0%" stop-color="#ff512f"/><stop offset="7.14%" stop-color="#fe572e"/><stop offset="14.29%" stop-color="#fd5e2d"/><stop offset="21.43%" stop-color="#fd632c"/><stop offset="28.57%" stop-color="#fc692b"/><stop offset="35.71%" stop-color="#fb6e2a"/><stop offset="42.86%" stop-color="#fa7328"/><stop offset="50%" stop-color="#f97827"/><stop offset="57.14%" stop-color="#f87d26"/><stop offset="64.29%" stop-color="#f78224"/><stop offset="71.43%" stop-color="#f58622"/><stop offset="78.57%" stop-color="#f48b20"/><stop offset="85.71%" stop-color="#f38f1e"/><stop offset="92.86%" stop-color="#f1941c"/><stop offset="100%" stop-color="#f09819"/></linearGradient></defs><rect width="1440" height="600" fill="url(#bs-gradient)"/></svg>

Questions

How much grain removes banding?
1–4% overlay opacity is enough to hide 8-bit banding on wide, subtle gradients; 8–15% is a visible, stylistic film grain.
Grain or dither?
Dither adds noise before quantisation and removes banding, but needs a shader, canvas or pre-baked lossless image. Every CSS grain technique adds noise after quantisation, so it masks the band — which is why ~1% opacity is enough for anti-banding and 8–15% is the stylistic look.
Is feTurbulence slow?
A live SVG filter is expensive per paint. Rasterise the noise once into a small stitched tile (a data-URI) and tile it — that is what the export does.

More

mcpConnect your agent to the gradient engine
live endpoint

Point any MCP client at the shared gradients endpoint and your agent gets the same engine as this page — make_gradient returns CSS with the interpolation space, Tailwind v4, SVG, a PNG URL, a DTCG token and the contrast report in one call; brand_gradients derives the whole contact sheet from a brand or a palette. Read-only, no key. Every gradient is a /gradients/new/… link: open it, refine, paste it back.

endpointhttps://mcp.brandsweets.com/gradients/mcp
claude mcp add --transport http brandsweets-gradients https://mcp.brandsweets.com/gradients/mcp
try this prompt

make a sunset hero gradient with enough grain to hide banding at 1440px and give me the CSS

Want the agent to remember your picks and write them into a living brand? Create a free BrandSweets workspace.