Dither Plate
An image pressed through a halftone screen — ordered dithering with sixteen threshold matrices, from Bayer grids and real blue noise to spirals, zebra stripes and plasma, all running live in a fragment shader.
A free, copy-paste media component for React, Next.js and shadcn/ui — built with Tailwind CSS.
Configuration
Tweak these live from theConfigurecontrol in the preview.
Interaction Type
- Sixteen threshold maps, each a different texture
- Drop in your own image and export the plate
- Six cursor effects — lens, water, swirl, shatter
- Every control retunes the shader in real time
Dependency
No dependencies — plain React. Just drop the file in.
Point the CLI here once
A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:
npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/dither-plate.json — or copy the file from Source code below.
[ shadcn CLI 3.0 registry ]…or add the component in one command
Install via CLI
pnpm dlx shadcn add @harshdev-ui/dither-plateHow to use
Use Dither Plate when you want a photograph to read as print rather than as a photograph — an editorial hero, a band poster, a zine spread, an avatar treatment. Pass an image URL and it reduces the picture to a couple of tones and fills the gaps with a repeating threshold matrix, which is exactly how a halftone screen fakes greys out of solid ink.
The threshold map is the whole personality. Bayer grids give the crosshatch everyone recognises from early games; blue noise scatters the dots so evenly that the pattern disappears and you just read texture; circles and lines behave like a real print screen; spiral, zebra and plasma are frankly decorative. Pattern scale changes how coarse the screen is, and the screen angle rotates it the way a printer angles each colour plate.
Colours default to the foreground and background tokens, so the plate follows your theme without being told. Set ink and paper explicitly for a fixed duotone, or switch to RGB to dither each channel on its own and get banded colour instead of two-tone ink.
Give the parent a height — the canvas fits itself inside it, keeping the image's aspect ratio.
Basic usage
import { DitherPlate } from "@/components/harsh-ui/dither-plate";
const Demo = () => (
<div className="h-screen w-full">
<DitherPlate src="/portrait.jpg" />
</div>
);A coarse newsprint screen
A bigger pattern scale plus the classic 45° screen angle reads as newsprint.
<DitherPlate
src="/portrait.jpg"
pattern="circles"
patternScale={2.4}
patternAngle={45}
/>Fixed duotone
Ink and paper take any CSS colour. Left alone they track var(--foreground) and var(--background), so the plate flips with the theme.
<DitherPlate
src="/portrait.jpg"
ink="#e11d48"
paper="#fff7ed"
/>Colour dithering
RGB mode dithers each channel separately, which gives banded colour rather than two-tone ink.
<DitherPlate src="/portrait.jpg" colorMode="rgb" levels={3} />Chunky pixels
<DitherPlate src="/portrait.jpg" pixelSize={4} pattern="bayer4" />Exporting the plate
onReady hands back the canvas once the image has been drawn, so a data URL can be read straight off it.
const canvasRef = useRef(null);
<DitherPlate src={src} onReady={(c) => (canvasRef.current = c)} />
<button onClick={() => {
const link = document.createElement("a");
link.download = "plate.png";
link.href = canvasRef.current.toDataURL("image/png");
link.click();
}}>Export</button>Demo
import { DitherPlateDemo } from "@/components/demos/dither-plate-demo";
const Demo = () => (
<div className="h-screen w-full">
<DitherPlateDemo />
</div>
);Props
Notes
- Zero dependencies — raw WebGL 1 and a fragment shader. No three.js, no image library, no GUI library.
- Dithering is the textbook parallel problem: every pixel's result depends only on itself and one lookup. The CPU version this grew from ran a million-iteration loop on every slider tick, which is why its control panel was hidden behind a four-second fade. On the GPU the same work is a single pass and dragging a control stays real time.
- The threshold matrix is uploaded as a tiny LUMINANCE texture — 8×8 for most maps — sampled with NEAREST and REPEAT, so the shader indexes it in screen space and the pattern tiles for free.
- Blue noise is generated with real void-and-cluster rather than a hand-written table: place each next sample in the emptiest spot, then rank every cell by when it was placed. It matters, because the whole point of blue noise is that all 64 thresholds are distinct and never line up — an approximation with repeated values just bands.
- Screen angle rotates the coordinates used to index the matrix, not the image, which is what printers do when they angle each colour plate to avoid moiré.
- Ink and paper are resolved by the browser rather than parsed here, so any CSS colour works and var(--foreground) tracks the theme. A MutationObserver on the document class redraws when the theme flips.
- preserveDrawingBuffer stays on, because the plate is a still image people export and toDataURL needs the buffer to survive past the draw call.
- Renders on demand rather than in a rAF loop — the output is a still, so a frame is only drawn when a prop, the size or the theme changes.
- Cursor effects warp the coordinate before anything else is computed, so the dot grid itself bends rather than a highlight being painted over a flat plate. Water layers three decaying ring frequencies plus a slow swell — a single sine reads as a machine, damped and layered reads wet.
- Relief shading treats the threshold matrix as a height field and takes its gradient as a surface normal, so the emboss follows whatever shape the chosen pattern actually makes instead of assuming one round dot per cell.
- Layout and computed styles are read when they can change, not per frame. getBoundingClientRect and getComputedStyle both force synchronous recalculation, and calling them inside a 60fps loop is what makes a canvas feel heavy on a phone.
- The backing store is capped by total fragment count rather than pixel ratio alone — a 3× phone asked for a full-bleed plate is millions of fragments a frame, and the dither is coarse enough that nobody can see the difference.
- Pointer events cover mouse, pen and touch, and touch-action is deliberately left alone so dragging across the plate still scrolls the page on a phone.
- Recreated from a CodePen by ol-ivier (https://codepen.io/ol-ivier). The threshold-map ideas are theirs; the shader port, the blue-noise generator, duotone and RGB modes, the screen angle and the theme-aware colours are this version's.
Source code
The complete dither-plate.tsx — toggle TSX / JSX to copy it in either language.
"use client";
import { useEffect, useRef, useState } from "react";
/**
* Harsh Dev UI · Developed by Harsh Pandav
* https://harshpandav.dev · hello@harshpandav.dev
*
* Ordered dithering as a printing plate: an image is reduced to a handful of
* tones and the gaps are filled with a repeating threshold matrix, the way a
* halftone screen fakes greys out of pure ink.
*
* The whole filter is a fragment shader. Dithering is per-pixel and completely
* independent, so the CPU version this grew from re-ran a million-iteration
* loop on every slider tick; on the GPU the same work is one parallel pass and
* dragging a control stays real-time whatever the image size.
*/
export type DitherPattern =
| "bayer4"
| "bayer8"
| "blueNoise"
| "clustered"
| "organic"
| "circles"
| "lines"
| "artDeco"
| "diagonal"
| "spiral"
| "zebra"
| "stochastic"
| "waves"
| "web"
| "starburst"
| "plasma";
export type DitherColorMode = "duotone" | "rgb";
export type DitherHover =
| "none"
| "lift"
| "pinch"
| "swirl"
| "ripple"
| "water"
| "shatter";
export type DitherDotStyle = "dither" | "sphere";
export type DitherCutout = "none" | "alpha" | "dark" | "light";
export type DitherPlateProps = {
src: string;
pattern?: DitherPattern;
patternScale?: number;
patternAngle?: number;
colorMode?: DitherColorMode;
ink?: string;
paper?: string;
brightness?: number;
contrast?: number;
threshold?: number;
levels?: number;
pixelSize?: number;
invert?: boolean;
depth?: number;
dotStyle?: DitherDotStyle;
hover?: DitherHover;
hoverRadius?: number;
hoverLift?: number;
cutout?: DitherCutout;
cutoutLevel?: number;
cutoutSoftness?: number;
className?: string;
onReady?: (canvas: HTMLCanvasElement) => void;
onError?: () => void;
};
const normalise = (values: number[]) => {
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
return values.map((v) => (v - min) / span);
};
const rank = (values: number[]) =>
values
.map((v, i) => ({ v, i }))
.sort((a, b) => a.v - b.v)
.reduce<number[]>((out, entry, r) => {
out[entry.i] = r / (values.length - 1);
return out;
}, []);
const grid = (size: number, fn: (x: number, y: number, c: number) => number) => {
const out: number[] = [];
const centre = (size - 1) / 2;
for (let y = 0; y < size; y++)
for (let x = 0; x < size; x++) out.push(fn(x, y, centre));
return out;
};
function bayer(size: number) {
let matrix = [[0, 2], [3, 1]];
while (matrix.length < size) {
const n = matrix.length;
const next = Array.from({ length: n * 2 }, () => Array(n * 2).fill(0));
for (let y = 0; y < n; y++)
for (let x = 0; x < n; x++) {
const v = matrix[y][x];
next[y * 2][x * 2] = 4 * v;
next[y * 2][x * 2 + 1] = 4 * v + 2;
next[y * 2 + 1][x * 2] = 4 * v + 3;
next[y * 2 + 1][x * 2 + 1] = 4 * v + 1;
}
matrix = next;
}
const total = size * size;
return matrix.flat().map((v) => v / total);
}
/**
* Real void-and-cluster, not a hand-typed table. Blue noise is the one matrix
* where the distribution is the whole point — repeatedly placing each next
* sample in the emptiest spot is what keeps the dots from ever lining up, and
* an approximation with duplicate thresholds just bands.
*/
function blueNoise(size: number) {
const total = size * size;
const sigma = 1.5;
const energy = new Float32Array(total);
let seed = 0x2f6e2b1;
const random = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
const splat = (index: number, sign: number) => {
const px = index % size;
const py = (index / size) | 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
let dx = Math.abs(x - px);
let dy = Math.abs(y - py);
if (dx > size / 2) dx = size - dx;
if (dy > size / 2) dy = size - dy;
energy[y * size + x] +=
sign * Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma));
}
}
};
const reset = (from: Uint8Array) => {
energy.fill(0);
for (let i = 0; i < total; i++) if (from[i]) splat(i, 1);
};
const pick = (from: Uint8Array, wanted: 0 | 1, want: "max" | "min") => {
let best = -1;
let bestValue = want === "max" ? -Infinity : Infinity;
for (let i = 0; i < total; i++) {
if (from[i] !== wanted) continue;
if (want === "max" ? energy[i] > bestValue : energy[i] < bestValue) {
bestValue = energy[i];
best = i;
}
}
return best;
};
const prototype = new Uint8Array(total);
const ones = Math.max(1, Math.round(total / 10));
for (let placed = 0; placed < ones; ) {
const i = Math.floor(random() * total);
if (prototype[i]) continue;
prototype[i] = 1;
splat(i, 1);
placed++;
}
for (;;) {
const cluster = pick(prototype, 1, "max");
prototype[cluster] = 0;
splat(cluster, -1);
const gap = pick(prototype, 0, "min");
if (gap === cluster) {
prototype[cluster] = 1;
splat(cluster, 1);
break;
}
prototype[gap] = 1;
splat(gap, 1);
}
const order = new Float32Array(total);
const working = new Uint8Array(total);
working.set(prototype);
reset(working);
for (let r = ones - 1; r >= 0; r--) {
const cluster = pick(working, 1, "max");
working[cluster] = 0;
splat(cluster, -1);
order[cluster] = r;
}
working.set(prototype);
reset(working);
for (let r = ones; r < total; r++) {
const gap = pick(working, 0, "min");
working[gap] = 1;
splat(gap, 1);
order[gap] = r;
}
return Array.from(order, (v) => v / (total - 1));
}
const CUTOUT_MODES: Record<DitherCutout, number> = {
none: 0,
alpha: 1,
dark: 2,
light: 3,
};
/** modes whose motion continues once the pointer stops moving */
const ANIMATED_MODES = new Set([4, 5]);
const HOVER_MODES: Record<DitherHover, number> = {
none: 0,
lift: 1,
pinch: 2,
swirl: 3,
ripple: 4,
water: 5,
shatter: 6,
};
const PATTERNS: Record<DitherPattern, { data: number[]; size: number }> = {
bayer4: { data: bayer(4), size: 4 },
bayer8: { data: bayer(8), size: 8 },
blueNoise: { data: blueNoise(8), size: 8 },
clustered: {
data: normalise(grid(6, (x, y, c) => Math.hypot(x - c, y - c))),
size: 6,
},
organic: {
data: rank(
grid(8, (x, y, c) => {
const radius = Math.hypot((x - c) / 4, (y - c) / 4);
const noise = (Math.sin(x * 2.3) * Math.cos(y * 1.9) + 1) / 2;
return Math.max(0, 1 - radius) * 0.55 + noise * 0.45;
}),
),
size: 8,
},
circles: {
data: normalise(
grid(8, (x, y, c) => {
const d = Math.hypot(x - c, y - c) / Math.hypot(c, c);
return Math.sin(d * Math.PI * 1.2) * 0.7 + 0.3;
}),
),
size: 8,
},
lines: {
data: normalise(
grid(
8,
(x, y) =>
((Math.sin(x * 1.8) + 1) / 2) * 0.6 + ((Math.cos(y * 1.5) + 1) / 2) * 0.4,
),
),
size: 8,
},
artDeco: {
data: normalise(
grid(8, (x, y) => {
const diag = (x + y) % 16;
return (diag / 16 + Math.sin(x * 0.8) * Math.cos(y * 0.8) * 0.2) % 1;
}),
),
size: 8,
},
diagonal: {
data: normalise(
grid(
8,
(x, y) => (x + y) / 16 + (Math.sin(x * 1.2) + Math.cos(y * 1.2)) * 0.25,
),
),
size: 8,
},
spiral: {
data: normalise(
grid(8, (x, y, c) => {
const angle = Math.atan2(y - c, x - c);
const radius = Math.hypot(x - c, y - c) / (c + 0.5);
return (angle / (Math.PI * 2) + radius * 1.5) % 1;
}),
),
size: 8,
},
zebra: {
data: normalise(grid(8, (x, y) => (Math.sin(x * 0.9 + y * 1.2) + 1) / 2)),
size: 8,
},
stochastic: {
data: normalise(
grid(8, (x, y) =>
Math.abs((Math.sin(x * 12.9898) * Math.cos(y * 78.233 + 43758.5453)) % 1),
),
),
size: 8,
},
waves: {
data: normalise(
grid(8, (x, y, c) => (Math.sin(Math.hypot(x - c, y - c) * 1.8) + 1) / 2),
),
size: 8,
},
web: {
data: normalise(
grid(8, (x, y, c) => {
const angle = Math.atan2(y - c, x - c);
const radius = Math.hypot(x - c, y - c) / (c + 0.5);
return (
(radius +
Math.sin(radius * Math.PI * 4) * 0.4 +
Math.sin(angle * 6) * 0.3) %
1
);
}),
),
size: 8,
},
starburst: {
data: normalise(
grid(8, (x, y, c) => {
const angle = Math.atan2(y - c, x - c);
const radius = Math.hypot(x - c, y - c) / (c + 0.5);
return (radius * 0.5 + Math.abs(Math.cos(angle * 5)) * 0.6) % 1;
}),
),
size: 8,
},
plasma: {
data: normalise(
grid(8, (x, y) => {
let value = 0;
let amplitude = 0.5;
let frequency = 1;
for (let octave = 0; octave < 4; octave++) {
value += amplitude * Math.sin(x * frequency * 0.8) * Math.cos(y * frequency * 0.7);
value += amplitude * Math.sin((x + y) * frequency * 0.6) * 0.5;
amplitude *= 0.5;
frequency *= 2;
}
return (value + 1) / 2;
}),
),
size: 8,
},
};
const VERTEX_SHADER = `
attribute vec2 aPosition;
varying vec2 vUv;
void main() {
vUv = aPosition;
gl_Position = vec4(aPosition * 2.0 - 1.0, 0.0, 1.0);
}
`;
const FRAGMENT_SHADER = `
precision highp float;
varying vec2 vUv;
uniform sampler2D uImage;
uniform sampler2D uPattern;
uniform vec2 uResolution;
uniform float uPatternSize;
uniform float uPatternScale;
uniform float uPatternAngle;
uniform float uBrightness;
uniform float uContrast;
uniform float uThreshold;
uniform float uLevels;
uniform float uPixelSize;
uniform float uInvert;
uniform float uRgb;
uniform vec3 uInk;
uniform vec3 uPaper;
uniform float uDepth;
uniform float uDotStyle;
uniform float uHover;
uniform float uHoverRadius;
uniform float uHoverLift;
uniform vec2 uPointer;
uniform float uPointerAmount;
uniform float uCutout;
uniform float uCutoutLevel;
uniform float uCutoutSoft;
uniform float uTime;
float quantise(float value, float t) {
float steps = max(uLevels - 1.0, 1.0);
float v = clamp(value, 0.0, 1.0) * steps;
float lower = floor(v);
return (lower + step(t, v - lower)) / steps;
}
vec3 tone(vec3 c) {
vec3 adjusted = ((c * 255.0 - 128.0) * uContrast + uBrightness) / 255.0;
adjusted = clamp(adjusted, 0.0, 1.0);
if (uInvert > 0.5) adjusted = 1.0 - adjusted;
return clamp(adjusted + uThreshold, 0.0, 1.0);
}
vec2 spin(vec2 v, float a) {
return vec2(v.x * cos(a) - v.y * sin(a), v.x * sin(a) + v.y * cos(a));
}
const vec3 LIGHT = vec3(-0.42, 0.52, 0.75);
const vec3 GREY = vec3(0.299, 0.587, 0.114);
float toneAt(vec2 uv) {
vec3 c = texture2D(uImage, vec2(uv.x, 1.0 - uv.y)).rgb;
return tone(vec3(dot(c, GREY))).r;
}
void main() {
float a = radians(uPatternAngle);
float step_ = max(uPatternScale, 0.05);
// The lens is an inverse map: to magnify, this fragment reads from a source
// point pulled toward the cursor. Warping the coordinate before anything else
// means the dot grid itself bulges, rather than a highlight being painted on
// top of a flat plate.
vec2 frag = gl_FragCoord.xy;
if (uHover > 0.5 && uPointerAmount > 0.001) {
vec2 d = frag - uPointer;
float t = clamp(length(d) / max(uHoverRadius, 1.0), 0.0, 1.0);
float dome = sqrt(max(1.0 - t * t, 0.0));
float edge = smoothstep(1.0, 0.72, t);
float amt = uHoverLift * dome * edge * uPointerAmount;
vec2 dir = normalize(d + vec2(0.0001));
if (uHover < 1.5) {
frag = uPointer + d / (1.0 + amt);
} else if (uHover < 2.5) {
frag = uPointer + d * (1.0 + amt);
} else if (uHover < 3.5) {
frag = uPointer + spin(d, amt * 2.8);
} else if (uHover < 4.5) {
frag = uPointer + d + dir * sin(t * 15.0 - uTime * 4.0) * amt * 30.0;
} else if (uHover < 5.5) {
// Water: rings at three frequencies, each decaying with distance, plus a
// slow swell. One sine reads as a machine; layered and damped reads wet.
float ring =
sin(t * 26.0 - uTime * 5.2) * exp(-t * 2.6)
+ sin(t * 15.0 - uTime * 3.4) * 0.65 * exp(-t * 1.7)
+ sin(t * 7.0 - uTime * 1.9) * 0.35;
float swell = sin(uTime * 0.9) * 0.12;
frag = uPointer + d + dir * (ring + swell) * amt * 26.0;
} else {
vec2 id = floor(spin(frag, a) / step_);
float rnd = fract(sin(dot(id, vec2(12.9898, 78.233))) * 43758.5453);
frag += (vec2(rnd, fract(rnd * 7.13)) - 0.5) * amt * 70.0;
}
}
vec2 uvBase = frag / uResolution;
vec2 cells = uResolution / max(uPixelSize, 1.0);
vec2 uv = (floor(uvBase * cells) + 0.5) / cells;
vec4 texel = texture2D(uImage, vec2(uv.x, 1.0 - uv.y));
vec3 src = texel.rgb;
// Keys the backdrop out. Alpha is exact when the source carries one; the dark
// and light keys are for artwork that was already flattened onto a flat plate.
float keep = 1.0;
if (uCutout > 0.5) {
float lo = uCutoutLevel - uCutoutSoft;
float hi = uCutoutLevel + uCutoutSoft;
if (uCutout < 1.5) {
keep = smoothstep(lo, hi, texel.a);
} else if (uCutout < 2.5) {
keep = smoothstep(lo, hi, dot(src, GREY));
} else {
keep = 1.0 - smoothstep(lo, hi, dot(src, GREY));
}
if (keep < 0.004) discard;
}
vec2 rotated = spin(frag, a);
vec3 result;
if (uDotStyle > 0.5) {
// One shaded sphere per cell, radius driven by that cell's tone — the
// classic halftone screen, and the only arrangement where a dot really is
// a dot and can be lit like one.
float cellPx = step_ * uPatternSize;
vec2 cellId = floor(rotated / cellPx);
vec2 centreRot = (cellId + 0.5) * cellPx;
vec2 centre = spin(centreRot, -a);
float ctone = toneAt(centre / uResolution);
float radius = sqrt(clamp(1.0 - ctone, 0.0, 1.0)) * 0.62;
vec2 local = (rotated - centreRot) / cellPx;
float dist = length(local);
float aa = 1.2 / cellPx;
float inside = 1.0 - smoothstep(radius - aa, radius + aa, dist);
float nz = sqrt(max(1.0 - pow(dist / max(radius, 0.001), 2.0), 0.0));
vec3 n = normalize(vec3(local / max(radius, 0.001), nz + 0.001));
float diffuse = clamp(dot(n, normalize(LIGHT)), 0.0, 1.0);
float spec = pow(diffuse, 34.0);
vec3 lit = mix(uInk, uPaper, clamp(diffuse * 0.5 + spec * 0.9, 0.0, 1.0) * uDepth);
result = mix(uPaper, lit, inside);
} else {
vec2 muv = (mod(floor(rotated / step_), uPatternSize) + 0.5) / uPatternSize;
float t = texture2D(uPattern, muv).r;
if (uRgb > 0.5) {
vec3 adjusted = tone(src);
result = vec3(
quantise(adjusted.r, t),
quantise(adjusted.g, t),
quantise(adjusted.b, t)
);
} else {
float mask = quantise(tone(vec3(dot(src, GREY))).r, t);
// The threshold matrix doubles as a height field: its gradient is a
// surface normal, so relief follows whatever shape the chosen pattern
// actually makes instead of assuming one round dot per cell.
float texel = 1.0 / uPatternSize;
float hL = texture2D(uPattern, muv - vec2(texel, 0.0)).r;
float hR = texture2D(uPattern, muv + vec2(texel, 0.0)).r;
float hD = texture2D(uPattern, muv - vec2(0.0, texel)).r;
float hU = texture2D(uPattern, muv + vec2(0.0, texel)).r;
vec3 n = normalize(vec3((hL - hR) * 2.0, (hD - hU) * 2.0, 0.85));
float diffuse = clamp(dot(n, normalize(LIGHT)), 0.0, 1.0);
float spec = pow(diffuse, 22.0);
float relief = clamp(diffuse * 0.55 + spec * 0.7, 0.0, 1.0);
vec3 lit = mix(uInk, uPaper, relief * uDepth);
result = mix(lit, uPaper, mask);
}
}
// premultiplied, so a keyed-out backdrop lets the page show through
gl_FragColor = vec4(result * keep, keep);
}
`;
function compile(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(
"[DitherPlate]",
gl.isContextLost()
? "context lost while compiling"
: `shader: ${gl.getShaderInfoLog(shader)}`,
);
gl.deleteShader(shader);
return null;
}
return shader;
}
function buildProgram(gl: WebGLRenderingContext) {
const vs = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
if (!vs || !fs) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("[DitherPlate] link:", gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
return program;
}
export function DitherPlate({
src,
pattern = "artDeco",
patternScale = 1,
patternAngle = 0,
colorMode = "duotone",
ink = "auto",
paper = "auto",
brightness = 128,
contrast = 1,
threshold = 0,
levels = 2,
pixelSize = 1,
invert = false,
depth = 0.4,
dotStyle = "dither",
hover = "water",
hoverRadius = 170,
hoverLift = 0.3,
cutout = "none",
cutoutLevel = 0.5,
cutoutSoftness = 0.08,
className,
onReady,
onError,
}: DitherPlateProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [epoch, setEpoch] = useState(0);
const opts = useRef({
pattern,
patternScale,
patternAngle,
colorMode,
ink,
paper,
brightness,
contrast,
threshold,
levels,
pixelSize,
invert,
depth,
dotStyle,
hover,
hoverRadius,
hoverLift,
cutout,
cutoutLevel,
cutoutSoftness,
});
const redraw = useRef<(() => void) | null>(null);
const onReadyRef = useRef(onReady);
const onErrorRef = useRef(onError);
useEffect(() => {
const next = {
pattern,
patternScale,
patternAngle,
colorMode,
ink,
paper,
brightness,
contrast,
threshold,
levels,
pixelSize,
invert,
depth,
dotStyle,
hover,
hoverRadius,
hoverLift,
cutout,
cutoutLevel,
cutoutSoftness,
};
const current = opts.current;
for (const key of Object.keys(next) as (keyof typeof next)[]) {
if (current[key] !== next[key]) {
opts.current = next;
redraw.current?.();
break;
}
}
onReadyRef.current = onReady;
onErrorRef.current = onError;
});
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const doc = canvas.ownerDocument;
const win = doc.defaultView ?? window;
const gl = canvas.getContext("webgl", {
alpha: true,
premultipliedAlpha: true,
antialias: false,
// the plate is a still image people export, so the buffer has to survive
// past the draw call for toDataURL to read anything back
preserveDrawingBuffer: true,
});
if (!gl || gl.isContextLost()) {
console.warn("[DitherPlate] WebGL unavailable");
onErrorRef.current?.();
return;
}
const onContextLost = (e: Event) => e.preventDefault();
const onContextRestored = () => setEpoch((v) => v + 1);
canvas.addEventListener("webglcontextlost", onContextLost);
canvas.addEventListener("webglcontextrestored", onContextRestored);
const program = buildProgram(gl);
if (!program) {
canvas.removeEventListener("webglcontextlost", onContextLost);
canvas.removeEventListener("webglcontextrestored", onContextRestored);
return;
}
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]),
gl.STATIC_DRAW,
);
const aPosition = gl.getAttribLocation(program, "aPosition");
const u = {
image: gl.getUniformLocation(program, "uImage"),
pattern: gl.getUniformLocation(program, "uPattern"),
resolution: gl.getUniformLocation(program, "uResolution"),
patternSize: gl.getUniformLocation(program, "uPatternSize"),
patternScale: gl.getUniformLocation(program, "uPatternScale"),
patternAngle: gl.getUniformLocation(program, "uPatternAngle"),
brightness: gl.getUniformLocation(program, "uBrightness"),
contrast: gl.getUniformLocation(program, "uContrast"),
threshold: gl.getUniformLocation(program, "uThreshold"),
levels: gl.getUniformLocation(program, "uLevels"),
pixelSize: gl.getUniformLocation(program, "uPixelSize"),
invert: gl.getUniformLocation(program, "uInvert"),
rgb: gl.getUniformLocation(program, "uRgb"),
ink: gl.getUniformLocation(program, "uInk"),
paper: gl.getUniformLocation(program, "uPaper"),
depth: gl.getUniformLocation(program, "uDepth"),
dotStyle: gl.getUniformLocation(program, "uDotStyle"),
hover: gl.getUniformLocation(program, "uHover"),
hoverRadius: gl.getUniformLocation(program, "uHoverRadius"),
hoverLift: gl.getUniformLocation(program, "uHoverLift"),
pointer: gl.getUniformLocation(program, "uPointer"),
pointerAmount: gl.getUniformLocation(program, "uPointerAmount"),
cutout: gl.getUniformLocation(program, "uCutout"),
cutoutLevel: gl.getUniformLocation(program, "uCutoutLevel"),
cutoutSoft: gl.getUniformLocation(program, "uCutoutSoft"),
time: gl.getUniformLocation(program, "uTime"),
};
gl.useProgram(program);
gl.enableVertexAttribArray(aPosition);
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
const imageTexture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, imageTexture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.uniform1i(u.image, 0);
const patternTexture = gl.createTexture();
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, patternTexture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.uniform1i(u.pattern, 1);
let uploadedPattern = "";
const uploadPattern = (key: DitherPattern) => {
if (key === uploadedPattern) return;
const entry = PATTERNS[key] ?? PATTERNS.bayer8;
uploadedPattern = key;
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, patternTexture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.LUMINANCE,
entry.size,
entry.size,
0,
gl.LUMINANCE,
gl.UNSIGNED_BYTE,
new Uint8Array(entry.data.map((v) => Math.round(v * 255))),
);
gl.uniform1f(u.patternSize, entry.size);
};
const probe = doc.createElement("span");
probe.style.display = "none";
canvas.parentElement?.appendChild(probe);
const resolveColor = (value: string): [number, number, number] => {
probe.style.color = "";
probe.style.color = value;
const [r = 0, g = 0, b = 0] = win
.getComputedStyle(probe)
.color.replace(/[^\d.,]/g, "")
.split(",")
.map(Number);
return [r / 255, g / 255, b / 255];
};
/**
* "auto" reads the theme rather than naming a token, because which of
* foreground and background is the bright one flips between light and dark.
* Pinning ink to either would print the picture as its own negative in one
* of the two themes.
*/
const luminance = ([r, g, b]: [number, number, number]) =>
0.2126 * r + 0.7152 * g + 0.0722 * b;
// getComputedStyle forces a style recalc, so resolving on every frame would
// stall the hover animation. Colours only change when a prop or the theme
// does, and both invalidate this.
let palette: { ink: number[]; paper: number[]; key: string } | null = null;
const invalidatePalette = () => {
palette = null;
};
const paletteFor = (inkValue: string, paperValue: string) => {
const key = `${inkValue}|${paperValue}`;
if (palette && palette.key === key) return palette;
const auto =
inkValue === "auto" || paperValue === "auto"
? (() => {
const fg = resolveColor("var(--foreground)");
const bg = resolveColor("var(--background)");
return luminance(fg) < luminance(bg)
? { dark: fg, light: bg }
: { dark: bg, light: fg };
})()
: null;
palette = {
key,
ink: inkValue === "auto" ? auto!.dark : resolveColor(inkValue),
paper: paperValue === "auto" ? auto!.light : resolveColor(paperValue),
};
return palette;
};
let image: HTMLImageElement | null = null;
let disposed = false;
let frame = 0;
let clock = 0;
// Layout reads are forced reflows, so the box is measured only when it can
// actually change and cached in between.
let boxW = 1;
let boxH = 1;
let visualScale = 1;
const measure = () => {
const parent = canvas.parentElement;
if (!parent) return;
// Layout size, NOT getBoundingClientRect: an ancestor may CSS-scale the
// whole frame, and the transformed rect would size the canvas to the
// already-shrunk box, which then gets shrunk a second time on screen.
const rect = parent.getBoundingClientRect();
boxW = Math.max(1, parent.clientWidth || rect.width);
boxH = Math.max(1, parent.clientHeight || rect.height);
// …but do shrink the backing store by however much we are scaled down.
visualScale = rect.width > 0 ? Math.min(rect.width / boxW, 1) : 1;
};
measure();
// The plate is a still, so nothing animates until a pointer arrives. These
// chase their targets each frame and the loop stops once they arrive.
const pointer = { x: -1e4, y: -1e4, tx: -1e4, ty: -1e4, a: 0, ta: 0 };
const reduceMotion =
typeof win.matchMedia === "function" &&
win.matchMedia("(prefers-reduced-motion: reduce)").matches;
const draw = () => {
frame = 0;
if (disposed || !image || gl.isContextLost()) return;
const o = opts.current;
const aspect = image.naturalWidth / image.naturalHeight;
let cssW = boxW;
let cssH = cssW / aspect;
if (cssH > boxH) {
cssH = boxH;
cssW = cssH * aspect;
}
// Cap the fragment count rather than the pixel ratio alone: a 3× phone
// asked for a full-width plate is millions of fragments a frame, and the
// dither is already coarse enough that nobody can see the difference.
let ratio =
Math.min(win.devicePixelRatio || 1, 2) * Math.max(visualScale, 0.25);
const budget = 2_600_000;
if (cssW * cssH * ratio * ratio > budget) {
ratio = Math.max(1, Math.sqrt(budget / (cssW * cssH)));
}
const w = Math.max(1, Math.round(cssW * ratio));
const h = Math.max(1, Math.round(cssH * ratio));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`;
gl.viewport(0, 0, w, h);
}
uploadPattern(o.pattern);
const { ink: inkRgb, paper: paperRgb } = paletteFor(o.ink, o.paper);
gl.uniform2f(u.resolution, w, h);
gl.uniform1f(u.patternScale, Math.max(o.patternScale, 0.05) * ratio);
gl.uniform1f(u.patternAngle, o.patternAngle);
gl.uniform1f(u.brightness, o.brightness);
gl.uniform1f(u.contrast, o.contrast);
gl.uniform1f(u.threshold, o.threshold);
gl.uniform1f(u.levels, Math.max(2, Math.round(o.levels)));
gl.uniform1f(u.pixelSize, Math.max(1, o.pixelSize) * ratio);
gl.uniform1f(u.invert, o.invert ? 1 : 0);
gl.uniform1f(u.rgb, o.colorMode === "rgb" ? 1 : 0);
gl.uniform3f(u.ink, inkRgb[0], inkRgb[1], inkRgb[2]);
gl.uniform3f(u.paper, paperRgb[0], paperRgb[1], paperRgb[2]);
const hoverMode = HOVER_MODES[o.hover] ?? 0;
pointer.x += (pointer.tx - pointer.x) * 0.38;
pointer.y += (pointer.ty - pointer.y) * 0.38;
pointer.a += (pointer.ta - pointer.a) * 0.1;
clock += 1 / 60;
gl.uniform1f(u.depth, o.depth);
gl.uniform1f(u.dotStyle, o.dotStyle === "sphere" ? 1 : 0);
gl.uniform1f(u.hover, hoverMode);
gl.uniform1f(u.hoverRadius, Math.max(o.hoverRadius, 1) * ratio);
gl.uniform1f(u.hoverLift, o.hoverLift);
gl.uniform2f(u.pointer, pointer.x * ratio, (cssH - pointer.y) * ratio);
gl.uniform1f(u.pointerAmount, pointer.a);
gl.uniform1f(u.time, clock);
gl.uniform1f(u.cutout, CUTOUT_MODES[o.cutout] ?? 0);
gl.uniform1f(u.cutoutLevel, o.cutoutLevel);
gl.uniform1f(u.cutoutSoft, Math.max(o.cutoutSoftness, 0.001));
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 6);
const settling =
Math.abs(pointer.tx - pointer.x) > 0.4 ||
Math.abs(pointer.ty - pointer.y) > 0.4 ||
Math.abs(pointer.ta - pointer.a) > 0.002;
// Ripple and water keep travelling after the cursor stops, so they hold
// the loop open while any influence remains; the rest settle and stop.
const animated = ANIMATED_MODES.has(hoverMode) && pointer.a > 0.002;
if (hoverMode && (settling || animated)) {
frame = win.requestAnimationFrame(draw);
}
};
const schedule = () => {
if (!frame) frame = win.requestAnimationFrame(draw);
};
const track = (e: PointerEvent) => {
if (opts.current.hover === "none" || reduceMotion) return;
// offsetX/offsetY are already relative to the canvas, so there is no rect
// to cache and nothing to go stale when the page scrolls.
pointer.tx = e.offsetX;
pointer.ty = e.offsetY;
if (pointer.a === 0) {
pointer.x = pointer.tx;
pointer.y = pointer.ty;
}
pointer.ta = 1;
schedule();
};
const release = () => {
pointer.ta = 0;
schedule();
};
// Pointer events cover mouse, pen and touch. touch-action is left alone so
// a drag over the plate can still scroll the page on a phone.
canvas.addEventListener("pointermove", track, { passive: true });
canvas.addEventListener("pointerdown", track, { passive: true });
canvas.addEventListener("pointerleave", release, { passive: true });
canvas.addEventListener("pointercancel", release, { passive: true });
const loader = new Image();
loader.crossOrigin = "anonymous";
loader.decoding = "async";
loader.onload = () => {
if (disposed || gl.isContextLost()) return;
image = loader;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, imageTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, loader);
draw();
onReadyRef.current?.(canvas);
};
loader.onerror = () => {
if (!disposed) onErrorRef.current?.();
};
loader.src = src;
redraw.current = schedule;
const resizeObserver = new win.ResizeObserver(() => {
measure();
schedule();
});
// ResizeObserver cannot see an ancestor transform change, and that is
// exactly what a scaled thumbnail does after it measures itself.
const visibilityObserver = new win.IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
measure();
schedule();
}
});
if (canvas.parentElement) {
resizeObserver.observe(canvas.parentElement);
visibilityObserver.observe(canvas.parentElement);
}
const themeObserver = new win.MutationObserver(() => {
invalidatePalette();
schedule();
});
themeObserver.observe(doc.documentElement, {
attributes: true,
attributeFilter: ["class"],
subtree: true,
});
return () => {
disposed = true;
redraw.current = null;
if (frame) win.cancelAnimationFrame(frame);
resizeObserver.disconnect();
visibilityObserver.disconnect();
themeObserver.disconnect();
canvas.removeEventListener("webglcontextlost", onContextLost);
canvas.removeEventListener("webglcontextrestored", onContextRestored);
canvas.removeEventListener("pointermove", track);
canvas.removeEventListener("pointerdown", track);
canvas.removeEventListener("pointerleave", release);
canvas.removeEventListener("pointercancel", release);
loader.onload = null;
loader.onerror = null;
loader.src = "";
probe.remove();
if (!gl.isContextLost()) {
gl.deleteTexture(imageTexture);
gl.deleteTexture(patternTexture);
gl.deleteBuffer(quad);
gl.deleteProgram(program);
}
};
}, [src, epoch]);
return (
<div className={className} style={{ display: "grid", placeItems: "center", height: "100%", width: "100%", overflow: "hidden" }}>
<canvas ref={canvasRef} style={{ display: "block", imageRendering: "pixelated" }} />
</div>
);
}
Keep in mind
Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.
Contact
Found a bug or an issue? Drop a mail — hello@harshpandav.dev
License & Usage
- Free to use and modify in personal and commercial projects.
- Attribution to Harsh Dev UI is appreciated but not required.
- MIT licensed.