Implementing Noise/Grain Effects on a Website
Grain (film grain, noise texture) is one of the most frequently requested visual effects in modern web design. It removes screen sterility, adds texture, and makes gradients more organic. It can be implemented in several ways—from static SVG filters to animated Canvas noise.
Method 1: SVG filter + CSS (simplest)
The browser applies procedural noise through SVG feTurbulence. Almost zero CPU/GPU load.
<!-- Hidden SVG with filter -->
<svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0">
<defs>
<filter id="grain-filter" x="0%" y="0%" width="100%" height="100%"
color-interpolation-filters="sRGB">
<feTurbulence
type="fractalNoise"
baseFrequency="0.65"
numOctaves="3"
stitchTiles="stitch"
result="noise"
/>
<feColorMatrix type="saturate" values="0" in="noise" result="grayNoise"/>
<feBlend in="SourceGraphic" in2="grayNoise" mode="overlay" result="blended"/>
<feComponentTransfer in="blended">
<feFuncA type="linear" slope="1"/>
</feComponentTransfer>
</filter>
</defs>
</svg>
.grain-overlay {
position: fixed;
inset: 0;
z-index: 1000;
pointer-events: none;
opacity: 0.15;
filter: url(#grain-filter);
background: transparent;
}
/* Or on a specific element */
.hero-with-grain {
position: relative;
}
.hero-with-grain::after {
content: '';
position: absolute;
inset: 0;
opacity: 0.12;
filter: url(#grain-filter);
pointer-events: none;
}
Static—the noise doesn't move. Good for texturing gradients.
Method 2: CSS pseudo-element with base64 PNG
Pre-rendered PNG with noise, tiled via background-size. Fewer artifacts in some browsers.
.grain-texture::after {
content: '';
position: fixed;
inset: -200%; /* extend beyond for animation */
width: 400%;
height: 400%;
background-image: url('/textures/grain.png');
background-size: 200px 200px;
opacity: 0.08;
pointer-events: none;
z-index: 9999;
animation: grain-shift 0.2s steps(1) infinite;
}
@keyframes grain-shift {
0% { transform: translate(0, 0); }
10% { transform: translate(-5%, -10%); }
20% { transform: translate(-15%, 5%); }
30% { transform: translate(7%, -25%); }
40% { transform: translate(-5%, 25%); }
50% { transform: translate(-15%, 10%); }
60% { transform: translate(15%, 0%); }
70% { transform: translate(0%, 15%); }
80% { transform: translate(3%, 35%); }
90% { transform: translate(-10%, 10%); }
100%{ transform: translate(0%, 5%); }
}
Generate grain.png via Node.js:
// scripts/generate-grain.js
const { createCanvas } = require('canvas')
const fs = require('fs')
const SIZE = 256
const canvas = createCanvas(SIZE, SIZE)
const ctx = canvas.getContext('2d')
const imageData = ctx.createImageData(SIZE, SIZE)
const data = imageData.data
for (let i = 0; i < data.length; i += 4) {
const value = Math.floor(Math.random() * 255)
data[i] = value // R
data[i + 1] = value // G
data[i + 2] = value // B
data[i + 3] = 255 // A
}
ctx.putImageData(imageData, 0, 0)
fs.writeFileSync('./public/textures/grain.png', canvas.toBuffer('image/png'))
Method 3: Canvas with animated noise
Full control: update speed, grain size, opacity, color.
class GrainCanvas {
private canvas: HTMLCanvasElement
private ctx: CanvasRenderingContext2D
private rafId: number | null = null
private frameCount = 0
private readonly FRAME_SKIP = 2 // update every N frames
constructor(container: HTMLElement = document.body, opacity = 0.1) {
this.canvas = document.createElement('canvas')
this.canvas.style.cssText = `
position: fixed;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
opacity: ${opacity};
mix-blend-mode: overlay;
`
container.appendChild(this.canvas)
this.ctx = this.canvas.getContext('2d')!
this.resize()
window.addEventListener('resize', this.resize)
this.start()
}
private resize = () => {
// Use reduced resolution for performance
const scale = 0.5
this.canvas.width = window.innerWidth * scale
this.canvas.height = window.innerHeight * scale
}
private generateNoise() {
const { width, height } = this.canvas
const imageData = this.ctx.createImageData(width, height)
const buffer = new Uint32Array(imageData.data.buffer)
for (let i = 0; i < buffer.length; i++) {
const v = (Math.random() * 256) | 0
// Packed RGBA (little-endian): AABBGGRR
buffer[i] = (255 << 24) | (v << 16) | (v << 8) | v
}
this.ctx.putImageData(imageData, 0, 0)
}
private start() {
const tick = () => {
this.frameCount++
if (this.frameCount % this.FRAME_SKIP === 0) {
this.generateNoise()
}
this.rafId = requestAnimationFrame(tick)
}
this.rafId = requestAnimationFrame(tick)
}
destroy() {
if (this.rafId) cancelAnimationFrame(this.rafId)
window.removeEventListener('resize', this.resize)
this.canvas.remove()
}
}
new GrainCanvas(document.body, 0.08)
Method 4: WebGL shader noise (GLSL)
For grain over a WebGL scene or procedural noise with frequency control:
// Fragment shader — animated film grain
uniform float uTime;
uniform float uIntensity;
uniform vec2 uResolution;
varying vec2 vUv;
// Pseudo-random function
float rand(vec2 co) {
return fract(sin(dot(co.xy, vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
// Change seed each frame — grain "lives"
vec2 seed = vUv + fract(uTime * 37.0);
float grain = rand(seed * uResolution) * 2.0 - 1.0;
// Add to existing color
vec4 base = texture2D(uTexture, vUv);
base.rgb += grain * uIntensity;
gl_FragColor = base;
}
Grain over gradient: eliminating banding
Gradients on screens with limited color depth show bands. Grain effectively masks banding:
.gradient-section {
background: linear-gradient(135deg, #1a0050 0%, #0a1628 50%, #001a2e 100%);
position: relative;
}
.gradient-section::after {
content: '';
position: absolute;
inset: 0;
background-image: url('/textures/grain.png');
background-size: 150px;
opacity: 0.05;
animation: grain-shift 0.3s steps(1) infinite;
pointer-events: none;
}
Performance
| Method | CPU | GPU | Animation |
|---|---|---|---|
| SVG feTurbulence static | ~0 | low | no |
| CSS pseudo + PNG | ~0 | low | yes |
| Canvas | medium | ~0 | yes |
| WebGL shader | ~0 | minimal | yes |
Full-resolution Canvas at 60fps creates overhead. Solutions:
- Reduce canvas size (
scale = 0.5) and stretch via CSS - Update every 2–3 frames (
FRAME_SKIP) -
OffscreenCanvas+ Worker for separate thread
// OffscreenCanvas in Web Worker
// main.js
const canvas = document.getElementById('grain')
const offscreen = canvas.transferControlToOffscreen()
const worker = new Worker('/workers/grain-worker.js')
worker.postMessage({ canvas: offscreen }, [offscreen])
Timeline
SVG or PNG method with CSS animation — 2–3 hours. Canvas with intensity adjustment, prefers-reduced-motion toggle and React component — 1 day.







