How to add film grain to a gradient on a website

Grainy gradients with an SVG noise tile in CSS or per-pixel grain in a shader, and what each costs.

Updated

To add film grain to a gradient in CSS, layer a small tiling noise image over it. An inline SVG with feTurbulence makes the noise, and background-blend-mode: overlay mixes it into the colours underneath. It costs about 340 bytes of CSS, no image request and no JavaScript:

.grainy {
  background:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"),
    linear-gradient(135deg, #4f46e5, #db2777 55%, #f59e0b);
  background-size:
    200px 200px,
    auto;
  background-blend-mode: overlay, normal;
}

That grain is frozen in place. Real film grain changes every frame, and the gradient below draws it that way, per pixel, in a shader.

Made for launch day

How the CSS version works

The SVG is a 200×200 tile with three parts:

  • feTurbulence type='fractalNoise' draws Perlin noise. baseFrequency sets the grain size: 0.8 gives fine grain, 0.4 a coarser, blotchier texture. numOctaves='3' adds detail. stitchTiles='stitch' makes the tile's edges match, so the repeat has no visible seams.
  • feColorMatrix type='saturate' values='0' turns the noise grey. Coloured noise tints the gradient in random speckles, which reads as a broken image rather than grain.
  • background-blend-mode: overlay lightens the gradient where the noise is light and darkens it where the noise is dark. The noise layer comes first in the background list, so it sits on top, and normal applies to the gradient under it.

Overlay also shifts the colours a little, because the noise isn't perfectly centred on mid grey. Compare the result with the plain gradient and adjust the colour stops if it drifts. For a softer effect, put the noise on a pseudo-element instead, where you can lower its opacity.

The browser rasterizes the SVG once per tile size and then repeats the bitmap, so a full-screen grainy hero costs about the same as a plain one. Applying the same feTurbulence as a CSS filter on the element is a different matter: a filter runs over the element's whole area again every time it repaints. Keep noise in a background image.

Animate the grain

Grain that sits still looks like a dirty screen once anything else on the page moves. To make it flicker like film, put the tile on a pseudo-element and jump it around with a stepped animation:

.film {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  background: linear-gradient(135deg, #4f46e5, #db2777 55%, #f59e0b);
}

.film::after {
  content: "";
  position: absolute;
  z-index: -1;
  inset: -200px;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")
    0 0 / 200px 200px;
  mix-blend-mode: overlay;
  opacity: 0.6;
  pointer-events: none;
  animation: grain 0.5s steps(4) infinite;
}

@keyframes grain {
  to {
    transform: translate(-200px, 200px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .film::after {
    animation: none;
  }
}

The layer is 200px larger than the box on every side, so moving it never uncovers an edge. steps(4) makes it jump between four positions instead of sliding, which is what turns a drifting texture into flicker. The end point is a whole number of tiles away from the start, so the fourth jump lands back on the first position and the loop never shows. It animates transform, which browsers can move without repainting the noise.

isolation: isolate keeps the blend inside the box. Without it, mix-blend-mode blends the grain with whatever is behind the element too. It also makes .film its own stacking context, so z-index: -1 puts the grain above the gradient but under any text inside the box.

Four positions of the same tile is a cheap trick, and a close look shows it repeating. It's fine for a hero behind text. For grain that never repeats, generate it per pixel.

Per-pixel grain in a shader

A fragment shader can draw new grain for every pixel on every frame for almost nothing, because the GPU runs it in parallel for the whole canvas. The gradients.sh engine does it in a few lines at the end of every gradient's shader:

// n is triangular noise in -1..1, from two hashes of the pixel and frame
uvec2 ip = uvec2(px);
uint frame = uint(u_frame);
float n = hash01(ip, frame * 2u + 1u) + hash01(ip, frame * 2u + 2u) - 1.0;

// strongest in the midtones, a trace even at grain 0
float lum = dot(rgb, vec3(0.2126, 0.7152, 0.0722));
float amp = u_grain * 0.2 * (0.55 + 0.9 * lum * (1.0 - lum)) + 1.0 / 255.0;
rgb += n * amp;

Three details make it look like film rather than static:

  • Triangular noise. Adding two uniform random numbers gives values that cluster around zero, so most pixels move a little and few move a lot. One uniform random number looks harsher.
  • Midtone weighting. lum * (1.0 - lum) peaks at mid grey, so the grain is strongest in the midtones and fades toward black and white, the way film grain shows most in the midtones.
  • A seeded frame. The noise is a hash of the pixel and the frame number, not Math.random(). The frame number wraps at the loop length, so the grain repeats exactly when the gradient does. How seamless gradient loops work explains why that matters for video exports.

The 1.0 / 255.0 trace stays on at grain 0. It's too faint to see as grain, but it breaks up the banding that 8-bit colour causes in slow, dark gradients. For a deliberate, patterned alternative, see ordered dithering in a shader.

What grain costs

In CSS, the cost is the paint of one bitmap tile, and with the animated version, a compositor move four times per cycle.

In a shader, the grain itself is a few arithmetic operations per pixel. The cost is that grain is detail a canvas can't scale away. The gradients.sh engine normally renders soft gradients at a lower internal resolution under load and upscales them, which nobody notices on a blur. Fine grain would turn to mush, so at grain 0.3 and above it never renders below 0.8× resolution.

In video, grain is the most expensive thing you can add. Noise changes every pixel on every frame, which is exactly what video compression can't predict. In our measurements, the same 10-second 1080p H.264 clip grew from 1.09 MB without grain to 74.4 MB with visible grain. Background video vs animated gradient has the full table. AV1 can avoid most of this with film grain synthesis: the encoder strips the grain and stores a description of it, and the decoder adds it back on playback.

Add grain to a gradient in React

Every free gradients.sh component takes a grain prop from 0 to 1. Grain Gradient defaults to 0.5. Install it with the shadcn CLI:

pnpm dlx shadcn@latest add https://gradients.sh/r/grain-gradient.json
import { GrainGradient } from "@/components/grain-gradient";

<GrainGradient className="h-[480px]" grain={0.6}>
  {children}
</GrainGradient>;

Mesh Gradient, Aurora, Waves, Radial Glow and Linear Flow take the same prop, so grain={0.3} on a smooth mesh gives it a matte, printed finish. Before the shader's first frame, and on devices that can't run it, the component shows a CSS version of the gradient, with an SVG noise layer like the one above once grain is 0.2 or more. Visitors who prefer reduced motion see one still frame. The Grain Gradient docs list every prop.

A grainy gradient generator

To get an image or a video instead of code, open the gradients.sh editor. Pick a preset, move the Grain slider, and export a PNG or an MP4 loop. Tuning needs no account; exporting needs a free one. The grain is rendered per pixel into the file at the export size. In an MP4 it changes every frame and repeats with the loop, unless moving grain would push the file past its size budget; then the export holds one grain pattern still while the gradient moves. The free plan exports PNGs up to 1920px and 10-second MP4s up to 1080p60. The grainy gradient generator page collects the grainiest free presets to start from.