How it works

Noise from a single filter primitive

No image files, no canvas, no JavaScript: the grain is computed by the browser from an SVG filter, at whatever resolution the screen needs.

1. Generate: feTurbulence

<feTurbulence> is a built-in SVG filter primitive that implements Ken Perlin's noise function. For every pixel in the filter region it computes a pseudo-random but smoothly varying value per color channel (red, green, blue, alpha). There is no input image: the noise is generated from scratch, so it stays crisp at any size and zoom level.

type
fractalNoise sums the noise layers directly and looks soft and cloudy. turbulence sums their absolute values, which creates sharp creases, like veins in marble or licks of flame.
baseFrequency
How many noise cycles fit into one pixel. Around 0.6–1 you get pixel-sized grain; around 0.01 you get large, slow clouds. The attribute accepts two numbers (x and y), and the Stretch slider sets y as a multiple of x. A small ratio such as 0.05 smears the noise horizontally into a brushed-metal look.
numOctaves
How many layers are added together. Each extra octave doubles the frequency and halves the amplitude, adding finer detail on top. Beyond about 5 the difference is invisible, but every octave costs another full pass of the noise function.
seed
Picks a different, but repeatable, random pattern.
stitchTiles
Adjusts the frequency slightly so the noise wraps seamlessly at the edges of the filter region. That only matters if you tile the result, e.g. as a repeating background image.

2. Shape: color matrix and transfer functions

Raw turbulence is a pastel, semi-transparent mess. Two more primitives turn it into grain:

  1. <feColorMatrix type="saturate" values="0"> removes the color, so only the brightness noise remains (Monochrome).
  2. <feComponentTransfer> maps every channel through a linear function, out = slope × in + intercept. Setting the slope to the contrast and the intercept to 0.5 − 0.5 × contrast stretches the values around mid-grey, so the grain gets punchier without getting brighter or darker overall; Brightness shifts the intercept. A last <feFuncA slope="0" intercept="1"> discards the noisy alpha channel and makes the layer opaque (unless you enable Noisy alpha).

color-interpolation-filters="sRGB" matters. SVG filters compute in linear RGB by default, which makes the noise look washed out and lighter than the same numbers in sRGB.

3. Composite: blend it onto the page

The filter is applied to a full-size <rect> inside an absolutely positioned <svg> that covers the stage. On its own that would be a grey static screen. The grain becomes texture through compositing: opacity sets how strong it is, and mix-blend-mode decides how it mixes with the colors below. overlay and soft-light lighten the light parts and darken the dark parts, so the gradient keeps its colors and just gains texture. isolation: isolate on the container keeps the blend from reaching anything outside it.

This is the markup and CSS for your current settings:

<svg aria-hidden="true">
  <filter id="noise" x="0" y="0" width="100%" height="100%"
          color-interpolation-filters="sRGB">
    <feTurbulence type="fractalNoise" baseFrequency="0.8 0.8"
                  numOctaves="4" seed="0"
                  stitchTiles="stitch" />
    <feColorMatrix type="saturate" values="0" />
    <feComponentTransfer>
      <feFuncR type="linear" slope="1.5" intercept="-0.25" />
      <feFuncG type="linear" slope="1.5" intercept="-0.25" />
      <feFuncB type="linear" slope="1.5" intercept="-0.25" />
      <feFuncA type="linear" slope="0" intercept="1" />
    </feComponentTransfer>
  </filter>
  <rect width="100%" height="100%" filter="url(#noise)" />
</svg>
.stage { position: relative; isolation: isolate; }

.stage > svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  opacity: 0.35;
  mix-blend-mode: overlay;
}

4. Animate: film grain

Real film grain changes from frame to frame. The component imitates that by changing the seed on a requestAnimationFrame loop, throttled to the chosen frames per second. It is skipped entirely when the user has asked for reduced motion.

Two caveats. Every new seed makes the browser recompute the filter for the whole area, so keep the frame rate low (8–12 fps already reads as film). And coarse noise is a photosensitivity hazard: with a low base frequency, whole regions jump between light and dark on every frame. WCAG 2.3.1 allows at most three flashes per second, where a flash is a change of 10% or more in the average luminance of an area about 341×256 px. Measured at the most extreme settings, a base frequency of 0.02 already reaches that change in a single frame, while 0.08 stays around 0.013. So the component only animates at full speed when the coarser axis is at 0.1 or above, and caps everything else at 3 fps.

Performance notes

  • A static noise layer is rendered once and then cached by the browser, so it is cheap. It is recomputed when the element is resized or the filter attributes change.
  • For large, static surfaces, the same filter can be put into a data-URI SVG and used as a tiled background-image. With stitchTiles="stitch" the seams disappear.
  • Noise breaks up color banding in smooth gradients and blurs, which is why the frosted glass effect reuses this component as its grain layer.

Back to the demo ↑