CSS and web

CSS noise texture: adding grain to gradients with SVG

A faint layer of noise hides banding and makes flat screen colour feel like print or film. Here is the SVG filter behind it, tuned and ready to paste.

GradientlyVerified Gradiently account··5 min read
Cover: Muslin Anvil · GR·W4AX·4M

The short version

  • A CSS noise texture is usually made with an SVG feTurbulence filter, embedded as a small data URI image and tiled over a gradient.
  • The feTurbulence baseFrequency sets the grain size: higher values give finer grain, lower values give coarse, cloudy noise.
  • Noise works best laid over the gradient on a pseudo-element with a blend mode such as soft-light or overlay and a low opacity.
  • Grain hides gradient banding because it breaks the visible edges between neighbouring shades into fine, random variation.
  • A tiled noise image is cheap to render, while applying a live SVG filter to a large element, or animating it, costs far more.
On this page

A CSS noise texture is a fine, random speckle laid over a colour or gradient to give it grain. CSS has no noise function, so the standard source is SVG: the feTurbulence filter generates noise, you wrap it in a tiny SVG, embed that as a data URI, and tile it over your gradient at low strength. The result looks like film grain or printed paper, and it quietly fixes banding, the visible stepping that appears in large, smooth gradients.

Why grain makes colour look more expensive is covered in grainy gradients. This guide is the implementation.

The SVG noise filter

html
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240">
  <filter id="n">
    <feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="3" stitchTiles="stitch" />
    <feColorMatrix type="saturate" values="0" />
  </filter>
  <rect width="100%" height="100%" filter="url(#n)" />
</svg>
A 240 pixel tile of grey noise. Save it as noise.svg, or embed it as shown below.
AttributeValue hereWhat it controls
typefractalNoiseSoft, even grain. turbulence gives streakier, rippled noise.
baseFrequency0.8Grain size. Around 0.6 to 0.9 is fine grain; 0.1 to 0.3 is cloudy.
numOctaves3Layers of detail. Beyond about 4 the difference is hard to see.
stitchTilesstitchMakes the tile's edges match, so repeats have no seams.
feColorMatrix saturate 00Removes the random colour, leaving grey grain.
The settings that matter. The MDN feTurbulence reference documents the rest.

Embedding noise as a data URI

A separate file works, but embedding saves a request and keeps the effect in one place. In a data URI, characters that mean something in URLs must be encoded: < becomes %3C, > becomes %3E, # becomes %23 and % becomes %25. Use single quotes inside the SVG so they do not clash with the double quotes around the URL.

css
:root {
  --noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%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");
}
The same tile as a custom property you can reuse anywhere. Forgetting to encode # as %23 is the usual reason it shows nothing in Firefox.

Layering noise over a gradient

Put the noise on a pseudo-element above the gradient, then control it with two dials: a blend mode, which decides how the grey grain mixes with the colour beneath, and opacity, which decides how strong it is. soft-light keeps colours clean and is the gentlest; overlay adds more contrast. pointer-events: none keeps the layer from blocking clicks.

css
.grain {
  position: relative;
  isolation: isolate;
  background: linear-gradient(135deg, #1e1b4b, #7c3aed 55%, #f472b6);
}

.grain::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background-image: var(--noise);
  mix-blend-mode: soft-light;
  opacity: 0.45;
  pointer-events: none;
}
Grain between the gradient and the content: the text inside .grain stays crisp.
Three gradients that gain the most from grain. Dark, low-contrast fades like the middle one are where banding shows first.

If you prefer a single element with no pseudo-element, list the noise first in background-image and set background-blend-mode: soft-light, normal. You lose the separate opacity dial, so bake the strength into the SVG instead, for example with opacity='0.5' on the rect.

How much grain is enough

  1. 1

    Start faint

    Begin at opacity: 0.3 with soft-light. You should barely notice it at arm's length.

  2. 2

    Check the darkest area

    Banding lives in dark, slow fades. Raise opacity until the steps there dissolve, then stop.

  3. 3

    Match the grain to the size

    Full-screen heroes can take slightly coarser grain, around 0.6. Cards and buttons want fine grain, around 0.9.

  4. 4

    Check on a phone and a large monitor

    Grain that looks right on one screen can look like dust on another. Aim for texture you feel rather than see.

  5. 5

    Check text on top

    Grain must not reduce legibility. If small text sits on the texture, lower the strength or keep text on a calmer area.

Too much

  • Grain visible from across the room
  • Colours turn grey and dirty
  • Coarse, cloudy noise on small components
  • Text edges look fuzzy against the speckle

About right

  • Grain noticed only up close
  • Colours stay clean with soft-light
  • Fine grain sized to the element
  • Banding gone, text crisp

Performance of CSS noise

A tiled data URI is cheap: the browser renders the small tile once and repeats it, and a static layer is painted once. The expensive version is applying the filter live to a large element with filter: url(#noise), which runs the filter over every pixel whenever the area repaints. If you animate grain, for a flickering film look, shift the tiled layer with transform in a few jumpy steps rather than regenerating noise, and switch it off for reduced motion.

css
.grain {
  overflow: hidden;
}

.grain::after {
  inset: -50%;
  animation: flicker 0.6s steps(3) infinite;
}

@keyframes flicker {
  0%   { transform: translate(0, 0); }
  33%  { transform: translate(-3%, 2%); }
  66%  { transform: translate(2%, -3%); }
}

@media (prefers-reduced-motion: reduce) {
  .grain::after { animation: none; }
}
Animated grain by moving an oversized tile. The negative inset keeps the edges covered as it shifts.

A pre-rendered PNG or WebP noise tile of around 128 to 256 pixels is an equally good source if you already have one. Grain is also one ingredient of a wider family of surfaces, from paper to glass, covered in background texture. It is the finishing layer on a CSS mesh gradient too.

Grain is built into every Gradiently Mark as a material setting, alongside light and motion, so designs made with one carry texture without any code.

Muslin Anvil

GR·W4AX·4M

Muslin Anvil

See this Mark
A Mark with grain as part of its material, rendered live rather than layered on afterwards.

Questions people ask

How do I add a noise texture in CSS?

Embed an SVG with an feTurbulence filter as a data URI, tile it on a pseudo-element over your gradient, and set a blend mode such as soft-light with low opacity.

Why does my SVG noise data URI not show?

Usually an unencoded character. In the data URI, encode # as %23, % as %25 and the angle brackets as %3C and %3E, and use single quotes inside the SVG.

Does grain fix gradient banding?

Yes, in most cases. Random fine variation breaks up the hard edges between neighbouring shades, so the eye no longer sees steps.

What baseFrequency should I use for grain?

Around 0.6 to 0.9 gives fine film-like grain. Lower values such as 0.2 give large, cloudy noise.

Is CSS noise bad for performance?

A small tiled noise image is cheap. Live SVG filters on large elements and constantly regenerated noise are what cost the most.

Share this guide

Written by GradientlyVerified Gradiently account

The team behind Gradiently, a design tool built around Marks: living gradients that make everything you design look like yours.

See our profile