Skip to content
Kinetic
WebGLWebGL8 files

Stepped Film Grain

A full-screen transparent grain layer for stacking over hero art, video, or flat colour. The pixels are an ordinary hash, and everything that makes it look like film rather than television snow is in the clock: the noise offset is quantised to twelve changes per second, and the frames in between are not drawn at all. Grain cells are sized in CSS pixels, so the overlay weighs the same on a laptop and on a 3x phone.

threefragment-shadergrainoverlayquantised-time

Live preview

The real component, running in this page.

02

Install

Copies the source into your project. Nothing is imported from us at runtime.

$npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/stepped-film-grain.json
pulls inthree@^0.185.1

Also installs webgl, prng, use-raf, use-resize-observer, use-reduced-motion, use-visibility from this registry.

03

Prompt

The whole technique, written for an agent.

Build it with your agent

A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.

Claude CodeCursorCodexCopilotGemini CLI
.md
# Build: Stepped Film Grain

A full-screen transparent grain layer for stacking over hero art, video, or flat colour. The pixels are an ordinary hash, and everything that makes it look like film rather than television snow is in the clock: the noise offset is quantised to twelve changes per second, and the frames in between are not drawn at all. Grain cells are sized in CSS pixels, so the overlay weighs the same on a laptop and on a 3x phone.

Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.

## What it does

A transparent grain overlay whose pattern jumps twelve times a second instead of every frame, so it reads as film.

- Category: webgl
- Runtime cost: webgl
- Dependencies: three@^0.185.1

## How it works

1. A `PlaneGeometry(2, 2)` is written straight to clip space — `gl_Position = vec4(position.xy, 0.0, 1.0)` — so there are no matrices and no camera transform, only a fragment shader over the whole canvas.
2. Each fragment picks a lattice cell with `cell = floor(gl_FragCoord.xy / uPixelRatio / uCellSize) + uOffset`, dividing by the pixel ratio so a cell is a fixed number of CSS pixels rather than of device pixels.
3. `cell = mod(cell, uTile)` wraps the lattice at 170 cells, which keeps the hash argument small enough for float precision and turns the offset into a jump anywhere inside a finite, tiling field.
4. The value is `fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453)`, shaped into signed grain by `g = (n - 0.5) * uContrast + uLift`. The lift is film base fog: it raises the mean so the layer opens crushed blacks instead of only speckling them.
5. One normal-blended draw carries both directions: the colour is `vec3(step(0.0, g))` — white above the lift, black below — and the alpha is `abs(g) * uOpacity`, with anything under 0.0035 discarded.
6. The rAF callback computes `index = floor(elapsed * stepsPerSecond) % steps` and returns immediately if the index is unchanged, so the GPU sees twelve draws a second regardless of display refresh rate. On a step it reads `uOffset` from a seeded table of integer jumps and renders once.

## Constants — use these values

These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".

- `stepsPerSecond = 12`
  The filmic/snow dial. Below about 8 the pattern visibly ticks and reads as a stutter; past about 18 the eye fuses the steps into continuous shimmer and it is television snow again. 12 is 24 fps film with each pattern held for two frames.
- `steps = 24`
  Table length. At 12 steps a second this is a 2-second cycle before the sequence repeats — long enough that nothing breathes on a one-second beat, and the whole table is 48 floats.
- `tile = 170`
  Cells before the lattice wraps. Large enough that the repeat is imperceptible in unstructured noise; small enough that `dot(cell, vec2(12.9898, 78.233))` stays around 15 000, where `fract(sin(x) * 43758)` still has usable precision.
- `contrast = 0.24`
  Peak-to-peak swing before opacity, giving a signed range of ±0.12. Past ~0.4 the grain competes with the image it sits on; below ~0.1 it disappears once the browser composites it at 8 bits.
- `lift = 0.095`
  Just under half the contrast, so roughly 80% of samples land above zero. That asymmetry is what makes it read as an emulsion over the image rather than as symmetric static in front of it.
- `opacity = 0.4`
  Peak alpha works out at 0.4 x 0.215 ≈ 0.086. Above ~0.6 the layer stops being texture and starts being a visible veil over type.
- `cellSize = 1 CSS pixel`
  One cell per CSS pixel is the finest grain that is still DPR-invariant. Larger values give coarser, pushed-film grain; smaller values ask for detail below a CSS pixel that the backing store cannot hold.
- `maxDpr = 1`
  A grain cell is a whole CSS pixel, so a 2x backing store draws four device pixels of identical value per cell — four times the fill rate for a bit-identical image. This is the one full-screen shader that should not be rendered at device resolution.
- `ALPHA_EPSILON = 0.0035`
  Just under 1/255. Below it an 8-bit compositor shows nothing, but blending still costs a framebuffer read-modify-write — and on a lifted, low-contrast field a large share of pixels land there.

## API

```ts
function SteppedFilmGrain(props: { opacity?: number; contrast?: number; lift?: number; cellSize?: number; tile?: number; stepsPerSecond?: number; steps?: number; maxDpr?: number; seed?: number | string; className?: string }): JSX.Element
```

- `opacity: number` — default `0.4`
  Overall strength, multiplied onto the grain magnitude.
- `contrast: number` — default `0.24`
  Peak-to-peak swing of the noise before opacity.
- `lift: number` — default `0.095`
  Mean added to the noise — base fog, which biases the layer toward lightening.
- `cellSize: number` — default `1`
  Size of one grain cell in CSS pixels. Clamped to a 0.25 floor.
- `tile: number` — default `170`
  Cells before the lattice repeats. Clamped to a floor of 8.
- `stepsPerSecond: number` — default `12`
  How many times a second the pattern changes. Also the draw rate.
- `steps: number` — default `24`
  Entries in the seeded offset table; the cycle length is steps / stepsPerSecond seconds.
- `maxDpr: number` — default `1`
  Backing-store cap. 1 is deliberate; see the constant.
- `seed: number | string` — default `'stepped-film-grain'`
  Seeds the offset table so the sequence is stable across reloads and matches the server.
- `className: string`
  Merged onto the host element via `cn`.

## Reference implementation of the core

This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.

```tsx
export function SteppedFilmGrain({
  opacity = 0.4,
  contrast = 0.24,
  lift = 0.095,
  cellSize = 1,
  tile = 170,
  stepsPerSecond = 12,
  steps = 24,
  maxDpr = 1,
  seed = 'stepped-film-grain',
  className,
}: SteppedFilmGrainProps) {
  const host = useRef<HTMLDivElement>(null);
  const stage = useRef<Stage | null>(null);
  const [unsupported, setUnsupported] = useState(false);

  const { width, height } = useResizeObserver(host);
  const size = useRef({ width, height });
  size.current = { width, height };

  const reduced = useReducedMotion();
  const visible = useVisibility();
  const ready = width > 0 && height > 0;

  const stepCount = Math.max(1, Math.round(steps));

  useEffect(() => {
    const el = host.current;
    if (!el || !ready) return;

    if (!hasWebGL()) {
      setUnsupported(true);
      return;
    }

    const renderer = new THREE.WebGLRenderer({
      // Nothing here has a geometric edge, so MSAA would allocate a
      // multisample buffer the size of the canvas and resolve it every draw
      // for no visible difference.
      antialias: false,
      alpha: true,
      depth: false,
      stencil: false,
      powerPreference: 'low-power',
    });
    renderer.setPixelRatio(pixelRatio(maxDpr));
    renderer.setClearAlpha(0);

    const scene = new THREE.Scene();
    // The quad is authored in clip space, so the camera exists only because
    // three's render signature requires one.
    const camera = new THREE.Camera();

    const geometry = new THREE.PlaneGeometry(2, 2);

    const uniforms: Uniforms = {
      uPixelRatio: { value: renderer.getPixelRatio() },
      uOffset: { value: new THREE.Vector2() },
      uCellSize: { value: Math.max(cellSize, 0.25) },
      uTile: { value: Math.max(tile, 8) },
      uContrast: { value: contrast },
      uLift: { value: lift },
      uOpacity: { value: opacity },
    };

    const material = new THREE.ShaderMaterial({
      vertexShader: STEPPED_FILM_GRAIN_VERT,
      fragmentShader: STEPPED_FILM_GRAIN_FRAG,
      uniforms,
      transparent: true,
      depthTest: false,
      depthWrite: false,
      // Normal blending, not additive: the shader emits black as well as white
      // and additive could only lighten, which turns grain into haze.
      blending: THREE.NormalBlending,
    });

    const quad = new THREE.Mesh(geometry, material);
    quad.frustumCulled = false;
    scene.add(quad);

    /* The offset table. Seeded, not `Math.random`, so the sequence is identical
       on the server, on the client, and on every reload — and integer, so each
       jump lands exactly on the cell lattice. A non-integer offset resamples
       cell boundaries and the grain appears to slide a fraction of a cell,
       which is the crawl this component exists to avoid. */
    const rng = createRng(seed);
    const wrap = uniforms.uTile.value;
    const offsets: Array<[number, number]> = [];
    for (let i = 0; i < stepCount; i += 1) {
      offsets.push([Math.floor(rng() * wrap), Math.floor(rng() * wrap)]);
    }

    el.appendChild(renderer.domElement);
    renderer.domElement.style.display = 'block';
    renderer.domElement.style.width = '100%';
    renderer.domElement.style.height = '100%';

    let lastIndex = -1;

    const resize = (w: number, h: number) => {
      if (w <= 0 || h <= 0) return;
      renderer.setPixelRatio(pixelRatio(maxDpr));
      renderer.setSize(w, h, false);
      // Re-read rather than assume: dragging the window between a retina and a
      // non-retina display changes devicePixelRatio without changing the CSS
      // size, and a stale value rescales every grain cell.
      uniforms.uPixelRatio.value = renderer.getPixelRatio();
      // Resizing a canvas clears its drawing buffer, and this component only
      // draws on a step change — without a redraw here the overlay would be
      // blank until the next step, up to 1/stepsPerSecond later.
      renderer.render(scene, camera);
    };

    const step = (index: number) => {
      if (index === lastIndex) return;
      lastIndex = index;
      const pair = offsets[index] ?? offsets[0];
      if (pair) uniforms.uOffset.value.set(pair[0], pair[1]);
      renderer.render(scene, camera);
    };

    resize(size.current.width, size.current.height);
    stage.current = { resize, step };
    step(0);

    return () => {
      stage.current = null;
      geometry.dispose();
      material.dispose();
      disposeRenderer(renderer);
      renderer.domElement.remove();
    };
  }, [ready, opacity, contrast, lift, cellSize, tile, stepCount, maxDpr, seed]);

  useEffect(() => {
    stage.current?.resize(width, height);
  }, [width, height]);

  useRaf((_dt, elapsed) => {
    // Quantised time, not time. Everything filmic about this component is the
    // fact that this number changes twelve times a second and the draw call
    // is skipped in between.
    const index = Math.floor(elapsed * stepsPerSecond) % stepCount;
    stage.current?.step(index);
  }, ready && visible && !reduced && !unsupported);
```

## Requirements

These are not optional. A version of this component that skips any of them is
not finished.

**Cleanup.** Nulls the stage ref, disposes the quad geometry and the ShaderMaterial, calls `disposeRenderer` for `dispose` + `forceContextLoss` + a zeroed drawing buffer, then removes the canvas from the DOM. `useRaf` releases its own frame subscription, and there are no listeners of the component's own — sizing comes from the shared ResizeObserver and visibility from the shared document listener.

**Reduced motion.** The component reads `useReducedMotion` itself. No rAF subscription is opened, and the grain is drawn once at table entry 0 — the texture is fully present and correct, it simply never advances. That is the right degradation for grain: it is a surface quality, not a motion, and removing it entirely would change how the artwork beneath it reads.

**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.

**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.

## Known pitfalls — avoid these specifically

- Advancing the noise every frame. It is the single thing that separates film grain from TV static, and it also makes the effect frame-rate dependent — the same overlay churns twice as fast on a 120 Hz display. Quantising the offset fixes both at once.
- Sliding the offset by a constant per step instead of jumping to table entries. A constant increment makes the whole field crawl in one direction, and the eye locks onto and tracks that direction; uncorrelated jumps have nowhere to track.
- Using a fractional offset. Non-integer values move the lattice off the pixel grid, so cell boundaries resample and the grain shimmers within each step — the artefact the stepping was supposed to remove.
- Declaring `precision mediump float` out of habit. The hash argument reaches ~15 000, which a 16-bit float holds to three digits, so adjacent cells return the same value and the grain becomes flat blocks tens of pixels wide. This shader needs highp.
- Sizing grain in device pixels. `gl_FragCoord` is a device-pixel coordinate, so grain authored without dividing by the pixel ratio is three times finer and effectively invisible on the phone, while looking correct on the machine it was tuned on.
- Rendering the overlay at the device pixel ratio. A cell is one CSS pixel of constant value; a 2x buffer costs four times the fragments and produces the identical image.
- Forgetting that resizing a canvas clears its drawing buffer. A component that only draws on a step change goes blank for up to 1/stepsPerSecond after every resize, which during a window drag is a visible flicker — the resize path redraws explicitly.
- Using additive blending so the grain can only lighten. The result is haze rather than grain; emitting black or white with the magnitude in alpha gets both directions out of one normal-blended draw.

## Notes

- The host is `pointer-events-none` and `aria-hidden`. It is a decorative layer that must never intercept a click on the content underneath it.
- If WebGL is unavailable the component renders an empty box. A missing grain layer is a correct degradation — the content beneath is untouched.
- Stack it as the last child of a relatively-positioned container with `absolute inset-0`. It composites through the browser, so no blend mode is required, though `mix-blend-mode: overlay` is a reasonable variation over photography.

## Definition of done

A fine speckle over the content that visibly holds still for a beat and then changes, roughly twelve times a second — count it by eye and it should read as a flicker, not a fizz. Zoom the browser from 100% to 200% and the grain gets larger with the layout rather than staying pinned to device pixels. Resize the window and the overlay never blanks. With reduced motion the grain is still there and completely static.

When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
271 lines · 2324 words
04

Source

8 files, exactly as installed.

installs tocomponents/motion/stepped-film-grain.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { cn } from '@/lib/utils';
import { useRaf } from '@/hooks/use-raf';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { useResizeObserver } from '@/hooks/use-resize-observer';
import { useVisibility } from '@/hooks/use-visibility';
import { createRng } from '@/lib/prng';
import { disposeRenderer, hasWebGL, pixelRatio } from '@/lib/webgl';
import {
  STEPPED_FILM_GRAIN_FRAG,
  STEPPED_FILM_GRAIN_VERT,
} from '@/components/motion/shaders/stepped-film-grain';

/* ==========================================================================
   Stepped Film Grain
   --------------------------------------------------------------------------
   A transparent grain overlay whose pattern advances on a fixed clock instead
   of once per frame. That single decision is the whole component: per-frame
   noise reads as television snow — broken hardware — while noise that holds
   for four or five frames and then jumps reads as film.

   The step index is quantised to `stepsPerSecond`, and the offsets it walks
   are a small seeded table of integer jumps into a tiling noise lattice. Two
   consequences fall out of that:

     * the pattern changes wholesale rather than sliding, so nothing crawls in
       a direction the eye can track;
     * frames where the index has not changed are not drawn at all, so a
       120 Hz display costs the same twelve draws a second as a 60 Hz one.
   ========================================================================== */

export interface SteppedFilmGrainProps {
  /** Overall strength. 0.4 is the top of the range that still reads as stock. */
  opacity?: number;
  /** Peak-to-peak swing of the noise before opacity. */
  contrast?: number;
  /** Mean offset added to the noise — film base fog, which opens up blacks. */
  lift?: number;
  /** Size of one grain cell in CSS pixels, so grain is DPR-invariant. */
  cellSize?: number;
  /** Cells before the noise lattice repeats. */
  tile?: number;
  /** Pattern changes per second. This is the filmic/snow dial. */
  stepsPerSecond?: number;
  /** Entries in the offset table. Longer means a longer cycle before repeat. */
  steps?: number;
  /**
   * Backing-store cap. 1 by default: a grain cell is a whole CSS pixel, so
   * rendering at 2x quadruples the fill cost for an identical image.
   */
  maxDpr?: number;
  seed?: number | string;
  className?: string;
}

interface Uniforms {
  /** three types `uniforms` as an open record; without this it is not assignable. */
  [name: string]: THREE.IUniform;
  uPixelRatio: { value: number };
  uOffset: { value: THREE.Vector2 };
  uCellSize: { value: number };
  uTile: { value: number };
  uContrast: { value: number };
  uLift: { value: number };
  uOpacity: { value: number };
}

interface Stage {
  resize: (width: number, height: number) => void;
  step: (index: number) => void;
}

// #region critical
export function SteppedFilmGrain({
  opacity = 0.4,
  contrast = 0.24,
  lift = 0.095,
  cellSize = 1,
  tile = 170,
  stepsPerSecond = 12,
  steps = 24,
  maxDpr = 1,
  seed = 'stepped-film-grain',
  className,
}: SteppedFilmGrainProps) {
  const host = useRef<HTMLDivElement>(null);
  const stage = useRef<Stage | null>(null);
  const [unsupported, setUnsupported] = useState(false);

  const { width, height } = useResizeObserver(host);
  const size = useRef({ width, height });
  size.current = { width, height };

  const reduced = useReducedMotion();
  const visible = useVisibility();
  const ready = width > 0 && height > 0;

  const stepCount = Math.max(1, Math.round(steps));

  useEffect(() => {
    const el = host.current;
    if (!el || !ready) return;

    if (!hasWebGL()) {
      setUnsupported(true);
      return;
    }

    const renderer = new THREE.WebGLRenderer({
      // Nothing here has a geometric edge, so MSAA would allocate a
      // multisample buffer the size of the canvas and resolve it every draw
      // for no visible difference.
      antialias: false,
      alpha: true,
      depth: false,
      stencil: false,
      powerPreference: 'low-power',
    });
    renderer.setPixelRatio(pixelRatio(maxDpr));
    renderer.setClearAlpha(0);

    const scene = new THREE.Scene();
    // The quad is authored in clip space, so the camera exists only because
    // three's render signature requires one.
    const camera = new THREE.Camera();

    const geometry = new THREE.PlaneGeometry(2, 2);

    const uniforms: Uniforms = {
      uPixelRatio: { value: renderer.getPixelRatio() },
      uOffset: { value: new THREE.Vector2() },
      uCellSize: { value: Math.max(cellSize, 0.25) },
      uTile: { value: Math.max(tile, 8) },
      uContrast: { value: contrast },
      uLift: { value: lift },
      uOpacity: { value: opacity },
    };

    const material = new THREE.ShaderMaterial({
      vertexShader: STEPPED_FILM_GRAIN_VERT,
      fragmentShader: STEPPED_FILM_GRAIN_FRAG,
      uniforms,
      transparent: true,
      depthTest: false,
      depthWrite: false,
      // Normal blending, not additive: the shader emits black as well as white
      // and additive could only lighten, which turns grain into haze.
      blending: THREE.NormalBlending,
    });

    const quad = new THREE.Mesh(geometry, material);
    quad.frustumCulled = false;
    scene.add(quad);

    /* The offset table. Seeded, not `Math.random`, so the sequence is identical
       on the server, on the client, and on every reload — and integer, so each
       jump lands exactly on the cell lattice. A non-integer offset resamples
       cell boundaries and the grain appears to slide a fraction of a cell,
       which is the crawl this component exists to avoid. */
    const rng = createRng(seed);
    const wrap = uniforms.uTile.value;
    const offsets: Array<[number, number]> = [];
    for (let i = 0; i < stepCount; i += 1) {
      offsets.push([Math.floor(rng() * wrap), Math.floor(rng() * wrap)]);
    }

    el.appendChild(renderer.domElement);
    renderer.domElement.style.display = 'block';
    renderer.domElement.style.width = '100%';
    renderer.domElement.style.height = '100%';

    let lastIndex = -1;

    const resize = (w: number, h: number) => {
      if (w <= 0 || h <= 0) return;
      renderer.setPixelRatio(pixelRatio(maxDpr));
      renderer.setSize(w, h, false);
      // Re-read rather than assume: dragging the window between a retina and a
      // non-retina display changes devicePixelRatio without changing the CSS
      // size, and a stale value rescales every grain cell.
      uniforms.uPixelRatio.value = renderer.getPixelRatio();
      // Resizing a canvas clears its drawing buffer, and this component only
      // draws on a step change — without a redraw here the overlay would be
      // blank until the next step, up to 1/stepsPerSecond later.
      renderer.render(scene, camera);
    };

    const step = (index: number) => {
      if (index === lastIndex) return;
      lastIndex = index;
      const pair = offsets[index] ?? offsets[0];
      if (pair) uniforms.uOffset.value.set(pair[0], pair[1]);
      renderer.render(scene, camera);
    };

    resize(size.current.width, size.current.height);
    stage.current = { resize, step };
    step(0);

    return () => {
      stage.current = null;
      geometry.dispose();
      material.dispose();
      disposeRenderer(renderer);
      renderer.domElement.remove();
    };
  }, [ready, opacity, contrast, lift, cellSize, tile, stepCount, maxDpr, seed]);

  useEffect(() => {
    stage.current?.resize(width, height);
  }, [width, height]);

  useRaf((_dt, elapsed) => {
    // Quantised time, not time. Everything filmic about this component is the
    // fact that this number changes twelve times a second and the draw call
    // is skipped in between.
    const index = Math.floor(elapsed * stepsPerSecond) % stepCount;
    stage.current?.step(index);
  }, ready && visible && !reduced && !unsupported);
  // #endregion critical

  return (
    <div
      aria-hidden
      ref={host}
      className={cn('pointer-events-none relative h-full w-full overflow-hidden', className)}
    />
  );
}
05

API

Every parameter, its type and what happens when you change it.

function SteppedFilmGrain(props: { opacity?: number; contrast?: number; lift?: number; cellSize?: number; tile?: number; stepsPerSecond?: number; steps?: number; maxDpr?: number; seed?: number | string; className?: string }): JSX.Element
Parameters
opacitynumber = 0.4Overall strength, multiplied onto the grain magnitude.
contrastnumber = 0.24Peak-to-peak swing of the noise before opacity.
liftnumber = 0.095Mean added to the noise — base fog, which biases the layer toward lightening.
cellSizenumber = 1Size of one grain cell in CSS pixels. Clamped to a 0.25 floor.
tilenumber = 170Cells before the lattice repeats. Clamped to a floor of 8.
stepsPerSecondnumber = 12How many times a second the pattern changes. Also the draw rate.
stepsnumber = 24Entries in the seeded offset table; the cycle length is steps / stepsPerSecond seconds.
maxDprnumber = 1Backing-store cap. 1 is deliberate; see the constant.
seednumber | string = 'stepped-film-grain'Seeds the offset table so the sequence is stable across reloads and matches the server.
classNamestringMerged onto the host element via cn.
06

How it works

The technique in order, not the code in order.

  1. 01

    A PlaneGeometry(2, 2) is written straight to clip space — gl_Position = vec4(position.xy, 0.0, 1.0) — so there are no matrices and no camera transform, only a fragment shader over the whole canvas.

  2. 02

    Each fragment picks a lattice cell with cell = floor(gl_FragCoord.xy / uPixelRatio / uCellSize) + uOffset, dividing by the pixel ratio so a cell is a fixed number of CSS pixels rather than of device pixels.

  3. 03

    cell = mod(cell, uTile) wraps the lattice at 170 cells, which keeps the hash argument small enough for float precision and turns the offset into a jump anywhere inside a finite, tiling field.

  4. 04

    The value is fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453), shaped into signed grain by g = (n - 0.5) * uContrast + uLift. The lift is film base fog: it raises the mean so the layer opens crushed blacks instead of only speckling them.

  5. 05

    One normal-blended draw carries both directions: the colour is vec3(step(0.0, g)) — white above the lift, black below — and the alpha is abs(g) * uOpacity, with anything under 0.0035 discarded.

  6. 06

    The rAF callback computes index = floor(elapsed * stepsPerSecond) % steps and returns immediately if the index is unchanged, so the GPU sees twelve draws a second regardless of display refresh rate. On a step it reads uOffset from a seeded table of integer jumps and renders once.

Load-bearing constants

Load-bearing constants
stepsPerSecond = 12The filmic/snow dial. Below about 8 the pattern visibly ticks and reads as a stutter; past about 18 the eye fuses the steps into continuous shimmer and it is television snow again. 12 is 24 fps film with each pattern held for two frames.
steps = 24Table length. At 12 steps a second this is a 2-second cycle before the sequence repeats — long enough that nothing breathes on a one-second beat, and the whole table is 48 floats.
tile = 170Cells before the lattice wraps. Large enough that the repeat is imperceptible in unstructured noise; small enough that dot(cell, vec2(12.9898, 78.233)) stays around 15 000, where fract(sin(x) * 43758) still has usable precision.
contrast = 0.24Peak-to-peak swing before opacity, giving a signed range of ±0.12. Past ~0.4 the grain competes with the image it sits on; below ~0.1 it disappears once the browser composites it at 8 bits.
lift = 0.095Just under half the contrast, so roughly 80% of samples land above zero. That asymmetry is what makes it read as an emulsion over the image rather than as symmetric static in front of it.
opacity = 0.4Peak alpha works out at 0.4 x 0.215 ≈ 0.086. Above ~0.6 the layer stops being texture and starts being a visible veil over type.
cellSize = 1 CSS pixelOne cell per CSS pixel is the finest grain that is still DPR-invariant. Larger values give coarser, pushed-film grain; smaller values ask for detail below a CSS pixel that the backing store cannot hold.
maxDpr = 1A grain cell is a whole CSS pixel, so a 2x backing store draws four device pixels of identical value per cell — four times the fill rate for a bit-identical image. This is the one full-screen shader that should not be rendered at device resolution.
ALPHA_EPSILON = 0.0035Just under 1/255. Below it an 8-bit compositor shows nothing, but blending still costs a framebuffer read-modify-write — and on a lifted, low-contrast field a large share of pixels land there.
07

Behaviour

What it does about motion sensitivity, teardown and being finished.

Reduced motion

The component reads useReducedMotion itself. No rAF subscription is opened, and the grain is drawn once at table entry 0 — the texture is fully present and correct, it simply never advances. That is the right degradation for grain: it is a surface quality, not a motion, and removing it entirely would change how the artwork beneath it reads.

Cleanup

Nulls the stage ref, disposes the quad geometry and the ShaderMaterial, calls disposeRenderer for dispose + forceContextLoss + a zeroed drawing buffer, then removes the canvas from the DOM. useRaf releases its own frame subscription, and there are no listeners of the component's own — sizing comes from the shared ResizeObserver and visibility from the shared document listener.

Definition of done

A fine speckle over the content that visibly holds still for a beat and then changes, roughly twelve times a second — count it by eye and it should read as a flicker, not a fizz. Zoom the browser from 100% to 200% and the grain gets larger with the layout rather than staying pinned to device pixels. Resize the window and the overlay never blanks. With reduced motion the grain is still there and completely static.

Pitfalls

  • Advancing the noise every frame. It is the single thing that separates film grain from TV static, and it also makes the effect frame-rate dependent — the same overlay churns twice as fast on a 120 Hz display. Quantising the offset fixes both at once.
  • Sliding the offset by a constant per step instead of jumping to table entries. A constant increment makes the whole field crawl in one direction, and the eye locks onto and tracks that direction; uncorrelated jumps have nowhere to track.
  • Using a fractional offset. Non-integer values move the lattice off the pixel grid, so cell boundaries resample and the grain shimmers within each step — the artefact the stepping was supposed to remove.
  • Declaring precision mediump float out of habit. The hash argument reaches ~15 000, which a 16-bit float holds to three digits, so adjacent cells return the same value and the grain becomes flat blocks tens of pixels wide. This shader needs highp.
  • Sizing grain in device pixels. gl_FragCoord is a device-pixel coordinate, so grain authored without dividing by the pixel ratio is three times finer and effectively invisible on the phone, while looking correct on the machine it was tuned on.
  • Rendering the overlay at the device pixel ratio. A cell is one CSS pixel of constant value; a 2x buffer costs four times the fragments and produces the identical image.
  • Forgetting that resizing a canvas clears its drawing buffer. A component that only draws on a step change goes blank for up to 1/stepsPerSecond after every resize, which during a window drag is a visible flicker — the resize path redraws explicitly.
  • Using additive blending so the grain can only lighten. The result is haze rather than grain; emitting black or white with the magnitude in alpha gets both directions out of one normal-blended draw.

Notes

  • The host is pointer-events-none and aria-hidden. It is a decorative layer that must never intercept a click on the content underneath it.
  • If WebGL is unavailable the component renders an empty box. A missing grain layer is a correct degradation — the content beneath is untouched.
  • Stack it as the last child of a relatively-positioned container with absolute inset-0. It composites through the browser, so no blend mode is required, though mix-blend-mode: overlay is a reasonable variation over photography.