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.
Usage
/* Deepak Kumar E — https://craft.iam-deepak.space */ import { Dither } from "@/components/dither"; <Dither />Customise
Also the main cost knob — the field is computed at 1/scale resolution.
1 → 2×2, 2 → 4×4, 3 → 8×8. Larger matrices resolve smoother ramps.
2 is pure 1-bit. More tones weaken the effect but hold detail.
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.