Film Grain
A film grain overlay rendered on a canvas every frame via requestAnimationFrame. Each pixel gets a random grayscale value - at 18% opacity with an overlay blend mode, it reads as organic film noise over the grayscale architecture photo beneath.
Usage
tsx
123
import FilmGrain from './FilmGrain' <FilmGrain image="https://images.unsplash.com/photo-1486325212027-8081e485255e?w=1600&q=80" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
image | string | Unsplash city photo | URL of the background image the grain renders over |
Component
ts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
'use client' import { useEffect, useRef } from 'react'import Image from 'next/image' export interface FilmGrainProps { image?: string} const DEFAULTS = { image: 'https://images.unsplash.com/photo-1486325212027-8081e485255e?w=1600&q=80',} export default function FilmGrain({ image = DEFAULTS.image,}: FilmGrainProps) { const canvasRef = useRef<HTMLCanvasElement>(null) const containerRef = useRef<HTMLDivElement>(null) useEffect(() => { if (!canvasRef.current || !containerRef.current) return const canvas = canvasRef.current as HTMLCanvasElement const container = containerRef.current as HTMLDivElement const ctx = canvas.getContext('2d')! let rafId = 0 function resize() { canvas.width = container.clientWidth || container.offsetWidth canvas.height = container.clientHeight || container.offsetHeight } resize() const ro = new ResizeObserver(resize) ro.observe(container) function generateGrain() { const w = canvas.width const h = canvas.height if (!w || !h) { rafId = requestAnimationFrame(generateGrain); return } const imageData = ctx.createImageData(w, h) const data = imageData.data for (let i = 0; i < data.length; i += 4) { const v = Math.random() * 255 data[i] = v data[i+1] = v data[i+2] = v data[i+3] = 255 } ctx.putImageData(imageData, 0, 0) rafId = requestAnimationFrame(generateGrain) } generateGrain() return () => { cancelAnimationFrame(rafId) ro.disconnect() } }, []) return ( <div ref={containerRef} aria-label="Film grain effect over a canvas" style={{ position: 'relative', width: '100%', height: '100%', minHeight: '380px', borderRadius: 'var(--radius-lg)', overflow: 'hidden', }} > <Image src={image} alt="" aria-hidden="true" fill priority sizes="(max-width: 768px) 100vw, 50vw" style={{ objectFit: 'cover', filter: 'grayscale(100%) brightness(0.55)' }} /> <canvas ref={canvasRef} aria-hidden="true" style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', opacity: 0.18, mixBlendMode: 'overlay', pointerEvents: 'none' }} /> </div> )}