Skip to content
2D

Dither

An animated field crushed to a handful of tones through an ordered dither, so it reads as 1-bit newsprint or an early Macintosh screen. The matrix is the whole trick: rounding each pixel to the nearest colour gives flat bands, but adding a threshold that varies on a fixed 8×8 lattice before rounding turns that error into a crosshatch the eye integrates back into the original tone. Bayer rather than error diffusion, because an ordered matrix is position-based and stays locked to the pixel grid instead of crawling as the field moves.

Loading preview…

Usage

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Dither } from "@/components/dither"; <Dither />

Customise

3px

Also the main cost knob — the field is computed at 1/scale resolution.

3

1 → 2×2, 2 → 4×4, 3 → 8×8. Larger matrices resolve smoother ramps.

2

2 is pure 1-bit. More tones weaken the effect but hold detail.

1

The component

components/craft/2d/Dither.tsx

/* Deepak Kumar E — https://craft.iam-deepak.space */ import { useEffect, useRef } from "react"; /** * Ordered dithering — the plasma is quantised to a handful of colours through * a Bayer matrix, so it reads as a 1-bit newsprint or Macintosh screen rather * than a smooth gradient. * * The matrix is the whole trick. Rounding each pixel to the nearest available * colour produces flat bands; adding a per-pixel threshold that varies on a * fixed 8×8 lattice BEFORE rounding turns that error into a stable crosshatch * the eye integrates back into the original tone. It is the same maths that * let 1984 hardware show photographs in black and white, and it costs one * array lookup per pixel. * * Bayer specifically, not blue noise or Floyd–Steinberg: the ordered matrix is * deterministic and position-based, so the pattern stays locked to the pixel * grid as the field animates underneath it. Error diffusion would crawl and * shimmer, because each frame's error depends on the last. */ /** Recursive Bayer construction — each level quadruples the matrix. */function bayer(level: number): number[][] {  if (level === 0) return [[0]];  const smaller = bayer(level - 1);  const n = smaller.length;  const size = n * 2;  const out = Array.from({ length: size }, () => new Array<number>(size).fill(0));   for (let y = 0; y < n; y += 1) {    for (let x = 0; x < n; x += 1) {      const v = smaller[y][x] * 4;      out[y][x] = v;      out[y][x + n] = v + 2;      out[y + n][x] = v + 3;      out[y + n][x + n] = v + 1;    }  }  return out;} /** `#rrggbb` to a byte triple. */function toRgb(hex: string): [number, number, number] {  const match = /^#?([\da-f]{6})$/i.exec(hex.trim());  if (!match) return [0, 0, 0];  const n = parseInt(match[1], 16);  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];} export interface DitherProps {  ink?: string;  paper?: string;  /** Pixel size of one dithered cell. Larger reads as coarser newsprint. */  scale?: number;  /** Bayer matrix level: 1 → 2×2, 2 → 4×4, 3 → 8×8. */  level?: number;  speed?: number;  /** Tones between paper and ink. 2 is pure 1-bit. */  levels?: number;  still?: boolean;  className?: string;} export function Dither({  ink = "#c3f53c",  paper = "#0a0b0e",  scale = 3,  level = 3,  speed = 1,  levels = 2,  still = false,  className = "",}: DitherProps) {  const ref = useRef<HTMLCanvasElement>(null);   useEffect(() => {    const canvas = ref.current;    if (!canvas) return;    const ctx = canvas.getContext("2d", { alpha: false });    if (!ctx) return;     const matrix = bayer(Math.max(1, Math.min(3, Math.round(level))));    const size = matrix.length;    const inkRgb = toRgb(ink);    const paperRgb = toRgb(paper);     const reduced =      still ||      (typeof matchMedia === "function" &&        matchMedia("(prefers-reduced-motion: reduce)").matches);     /* Rendered at 1/scale resolution and stretched back up with smoothing       off. Drawing every device pixel and then quantising would cost scale²       times the work to produce an identical image — the whole point is that       the output has no detail finer than one cell. */    let width = 0;    let height = 0;    let image: ImageData | null = null;     const resize = () => {      const w = Math.max(1, Math.floor(canvas.clientWidth / scale));      const h = Math.max(1, Math.floor(canvas.clientHeight / scale));      if (!canvas.clientWidth || !canvas.clientHeight) return false;      if (w === width && h === height) return false;      width = w;      height = h;      canvas.width = w;      canvas.height = h;      image = ctx.createImageData(w, h);      ctx.imageSmoothingEnabled = false;      return true;    };     const draw = (time: number) => {      if (!image) return;      const data = image.data;      const steps = Math.max(2, Math.round(levels));       for (let y = 0; y < height; y += 1) {        for (let x = 0; x < width; x += 1) {          /* A plasma field: layered sines are smooth, cheap and — unlike             noise — produce the long sweeping gradients that show off what             dithering does to a tonal ramp. */          const fx = x / width;          const fy = y / height;          let v =            Math.sin(fx * 6.0 + time) +            Math.sin((fy * 5.0 - time) * 0.9) +            Math.sin((fx + fy) * 4.0 + time * 0.6) +            Math.sin(Math.hypot(fx - 0.5, fy - 0.5) * 12.0 - time * 1.2);          v = (v + 4) / 8; // → 0–1           /* The dither: offset by the matrix threshold, THEN quantise. */          const threshold = (matrix[y % size][x % size] + 0.5) / (size * size);          const shifted = v + (threshold - 0.5) / (steps - 1);          const tone = Math.max(0, Math.min(1, Math.round(shifted * (steps - 1)) / (steps - 1)));           const i = (y * width + x) * 4;          data[i] = paperRgb[0] + (inkRgb[0] - paperRgb[0]) * tone;          data[i + 1] = paperRgb[1] + (inkRgb[1] - paperRgb[1]) * tone;          data[i + 2] = paperRgb[2] + (inkRgb[2] - paperRgb[2]) * tone;          data[i + 3] = 255;        }      }       ctx.putImageData(image, 0, 0);    };     let frame = 0;    const start = performance.now();     const loop = (now: number) => {      resize();      draw(((now - start) / 1000) * speed);      frame = requestAnimationFrame(loop);    };     resize();    // Paint one frame synchronously before scheduling the loop, in both    // paths. Leaving the first draw to rAF means a blank panel for however    // long the first frame takes — which is forever in a background tab, and    // visibly long on a slow start.    draw(reduced ? 1.8 : 0);    if (!reduced) frame = requestAnimationFrame(loop);     const observer = new ResizeObserver(() => {      if (reduced && resize()) draw(1.8);    });    observer.observe(canvas);     return () => {      cancelAnimationFrame(frame);      observer.disconnect();    };  }, [ink, paper, scale, level, speed, levels, still]);   return (    <canvas      ref={ref}      aria-hidden      // The upscale must not be smoothed, or the dither blurs into the      // gradient it was quantised away from.      style={{ imageRendering: "pixelated" }}      className={`block h-full w-full ${className}`}    />  );} 

One file. Paste it in, delete what you do not need, change what you do. The "use client" directive is stripped above — add it back if you are on the Next.js App Router.

What it costs

Weight

+2.6KB gzipped · no new dependencies

Runs on

Main thread

Runs JavaScript every frame. Heavy work here competes with your own code and with scrolling — watch the per-frame budget below.

Reduced motion

Computes one frame of the field and stops. The dithered image is fully formed — the motion is what is removed, not the picture.

Accessibility
  • Canvas is aria-hidden; decorative by definition.
  • Adds no focusable elements.
  • High contrast between ink and paper is the point, so text over it needs a solid backing panel rather than sitting directly on the pattern.
Watch out
  • Per-pixel JavaScript on the main thread. Rendering at 1/scale keeps it cheap, but scale 1 on a large viewport is genuinely expensive — the cost is quadratic in the cell size.
  • imageRendering: pixelated is doing real work here. Without it the browser smooths the upscale and blurs the dither back into the gradient it was quantised away from.