Dither
Bayer 4×4 ordered dithering on any image — 1-bit mono or per-channel to N levels — with an optional animated CRT subpixel mask. Canvas 2D, no deps. Extracted from ai-arena.
Bayer 4×4 ordered dither · optional animated CRT subpixel mask
Usage
import { Dither } from "~/components/effects/dither";<Dither src="/photo.jpg" monochrome levels={4} />// add an animated CRT subpixel mask:<Dither src="/photo.jpg" animate crtStrength={0.6} crtJitter={0.15} />
Ordered dithering, then a CRT mask
Ordered (Bayer) dithering fakes more shades than you actually have by scattering pixels in a fixed pattern. A 4×4 matrix gives each pixel a tiny threshold offset based on its position; add that to the pixel value before quantizing and the rounding errors form an even, characteristic crosshatch instead of flat banding. Mono mode thresholds luminance to 1-bit; color mode quantizes each channel to levels steps.
The optional CRT pass is separate and animated. For each pixel it picks one active RGB subpixel (by column, staggered per scanline and advanced each frame) and dims the other two, with a little coordinate-seeded jitter. Cycling the active subpixel every frame gives that faint shimmering-phosphor look.
// 4×4 Bayer matrix → normalized thresholds in −0.5..0.5const BAYER = [0,8,2,10, 12,4,14,6, 3,11,1,9, 15,7,13,5];const T = BAYER.map((v) => (v + 0.5) / 16 - 0.5);// per pixel: nudge by the matrix threshold, then quantizeconst t = T[(y & 3) * 4 + (x & 3)];const q = Math.round((value / 255 + t) * (levels - 1));out = (q / (levels - 1)) * 255;
Props
| prop | type | default | description |
|---|---|---|---|
| src | string | — | Image URL (same-origin or CORS-enabled). |
| monochrome | boolean | false | 1-bit luminance dither instead of color. |
| levels | number | 4 | Quantization steps per channel (color mode). |
| animate | boolean | false | Run the animated CRT subpixel mask. |
| crtStrength | number | 0.6 | How dark the inactive subpixels go (0–1). |
| crtJitter | number | 0.15 | Per-pixel noise on the mask (0–1). |
| height | number | auto | Fixed height; otherwise from aspect ratio. |
From HypeDuel, a project I work on at B3.
Source
download .zipThe full implementation. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).
import React, { useEffect, useRef } from "react";import { cn } from "~/lib/utils";export interface DitherOptions {monochrome?: boolean;levels?: number;}// 4×4 Bayer matrix (ordered dithering)const BAYER_4X4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5,] as const;// Pre-compute threshold lookup as normalized offsets in range (−0.5 .. 0.5)const BAYER_THRESHOLDS = BAYER_4X4.map((v) => (v + 0.5) / 16 - 0.5);/** Bayer 4×4 ordered dithering, in place. Monochrome 1-bit, or per-channel to N levels. */export function applyBayer4x4Dither(imageData: ImageData,options: DitherOptions = {},) {const { monochrome = false, levels = 4 } = options;const w = imageData.width;const h = imageData.height;const data = imageData.data;const L = Math.max(2, levels);const maxLevelIndex = L - 1;const invLevelsMinus1 = 1 / maxLevelIndex;for (let y = 0; y < h; y++) {for (let x = 0; x < w; x++) {const idx = (y * w + x) * 4;const bayerIndex = (y & 3) * 4 + (x & 3);const thresholdOffset = BAYER_THRESHOLDS[bayerIndex];if (monochrome) {const r = data[idx];const g = data[idx + 1];const b = data[idx + 2];const luminance = 0.299 * r + 0.587 * g + 0.114 * b;const normalized = luminance / 255 + thresholdOffset;const color = (normalized < 0.5 ? 0 : 1) * 255;data[idx] = color;data[idx + 1] = color;data[idx + 2] = color;} else {for (let c = 0; c < 3; c++) {const val = data[idx + c];const normalized = val / 255 + thresholdOffset;const quantLevel = Math.min(maxLevelIndex,Math.max(0, Math.round(normalized * maxLevelIndex)),);data[idx + c] = Math.round(quantLevel * (255 * invLevelsMinus1));}}}}return imageData;}export function ditherCanvas(ctx: CanvasRenderingContext2D,options?: DitherOptions,) {const { width, height } = ctx.canvas;const src = ctx.getImageData(0, 0, width, height);const dst = applyBayer4x4Dither(src, options);ctx.putImageData(dst, 0, 0);return dst;}export interface CRTMaskOptions {strength?: number; // 0..1jitter?: number; // 0..1}/** RGB subpixel + scanline CRT mask with per-pixel jitter, in place. */export function applyCRTMask(imageData: ImageData,frame = 0,options: CRTMaskOptions = {},) {const { data, width, height } = imageData;const strength = Math.min(1, Math.max(0, options.strength ?? 0.6));const jitterAmp = Math.min(1, Math.max(0, options.jitter ?? 0.15));const baseInactive = 1 - strength;for (let y = 0; y < height; y++) {const rowShift = y & 1;for (let x = 0; x < width; x++) {const idx = (y * width + x) * 4;const subPixel = (x + rowShift + frame) % 3;let r = data[idx];let g = data[idx + 1];let b = data[idx + 2];const randSeed =((x * 374761393 + y * 668265263 + frame * 12345) >>> 0) & 0xffff;const noise = (randSeed / 0xffff - 0.5) * jitterAmp;const dim = Math.min(1, Math.max(0, baseInactive + noise));switch (subPixel) {case 0:g = Math.round(g * dim);b = Math.round(b * dim);break;case 1:r = Math.round(r * dim);b = Math.round(b * dim);break;case 2:r = Math.round(r * dim);g = Math.round(g * dim);break;}data[idx] = r;data[idx + 1] = g;data[idx + 2] = b;}}return imageData;}export interface DitherProps {src: string;alt?: string;height?: number;className?: string;style?: React.CSSProperties;/** Animate the CRT subpixel mask. */animate?: boolean;flickerFps?: number;crtStrength?: number;crtJitter?: number;monochrome?: boolean;levels?: number;}/*** Bayer-dithered <img>, optionally with an animated CRT subpixel mask.* Renders to a responsive canvas. Extracted from hypeduel (ai-arena).*/export const Dither: React.FC<DitherProps> = ({src,alt = "",height,className,style,animate,flickerFps = 60,crtStrength,crtJitter,monochrome,levels,}) => {const canvasRef = useRef<HTMLCanvasElement | null>(null);const containerRef = useRef<HTMLDivElement | null>(null);useEffect(() => {let rafId: number | undefined;let resizeObserver: ResizeObserver | undefined;const img = new window.Image();img.crossOrigin = "anonymous";img.src = src;const draw = (containerWidth: number) => {const canvas = canvasRef.current;if (!canvas) return;const ctx = canvas.getContext("2d", { willReadFrequently: true });if (!ctx) return;const aspectRatio = img.naturalHeight / img.naturalWidth;const w = containerWidth;const h = height ?? Math.round(containerWidth * aspectRatio);canvas.width = w;canvas.height = h;ctx.drawImage(img, 0, 0, w, h);const base = ditherCanvas(ctx, { monochrome, levels });if (animate) {let frame = 0;let last = performance.now();const loop = () => {const now = performance.now();const interval = 1000 / flickerFps;if (now - last >= interval) {last = now - ((now - last) % interval);const copy = new ImageData(new Uint8ClampedArray(base.data),base.width,base.height,);applyCRTMask(copy, frame++, {strength: crtStrength,jitter: crtJitter,});ctx.putImageData(copy, 0, 0);}rafId = requestAnimationFrame(loop);};rafId = requestAnimationFrame(loop);}};img.onload = () => {const container = containerRef.current;if (!container) return;draw(container.offsetWidth);resizeObserver = new ResizeObserver((entries) => {for (const entry of entries) {const newWidth = entry.contentRect.width;if (newWidth > 0) {if (rafId !== undefined) {cancelAnimationFrame(rafId);rafId = undefined;}draw(newWidth);}}});resizeObserver.observe(container);};return () => {if (rafId !== undefined) cancelAnimationFrame(rafId);if (resizeObserver) resizeObserver.disconnect();};}, [src, height, animate, flickerFps, crtStrength, crtJitter, monochrome, levels]);return (<div ref={containerRef} className={cn("w-full", className)} style={style}><canvasref={canvasRef}aria-label={alt}role="img"className="h-full w-full object-cover"/></div>);};