Dither Image

Bayer and blue-noise dithering of any image into a 2–4 ink palette, with a cursor loupe that sharpens, inverts or reveals.

  • Dither
  • Fashion / Media
  • React only · 0 deps
  • 1 file
Full screen

Browser support · WebGL2 with WebGL1 fallback. Without WebGL a CSS halftone gradient is shown.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/dither-image.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/dither/dither-image.tsx
1/**
2 * DitherImage — GPU ordered / blue-noise dithering of any image into a 2–4
3 * colour palette, with a cursor "loupe" that sharpens, inverts or reveals the
4 * photo underneath.
5 *
6 * Props
7 * - src?: string Image URL (loaded with crossOrigin="anonymous", cover-fitted).
8 * - draw?: (ctx, w, h)=>void Procedural source drawn to an offscreen canvas (used when no src).
9 * - palette?: string[] 2–4 hex colours, darkest → lightest. Default ink / paper.
10 * - pixelSize?: number Dither cell size in CSS px. Default 3.
11 * - algorithm?: "bayer2" | "bayer4" | "bayer8" | "blue-noise". Default "bayer8".
12 * - contrast?: number 1 = neutral. Default 1.15.
13 * - brightness?: number −0.5…0.5. Default 0.
14 * - lens?: "sharpen" | "invert" | "reveal" | "none". Default "sharpen".
15 * - lensRadius?: number CSS px. Default 140.
16 * - children Overlay content (type, UI) rendered above the canvas.
17 * CSS vars: --fd-dimg-radius, --fd-dimg-fallback.
18 *
19 * Browser support: WebGL2 with WebGL1 fallback. Without WebGL a CSS halftone
20 * gradient is shown. Accessibility: pass `alt` — the canvas gets role="img".
21 * Reduced motion: the loupe snaps instead of easing.
22 */
23import { useEffect, useRef, useState } from "react";
24import type { CSSProperties, MutableRefObject, ReactNode, RefObject } from "react";
25
26/* ───────────────────────── tiny WebGL stage ───────────────────────── */
27
28type GL = WebGLRenderingContext | WebGL2RenderingContext;
29interface Stage {
30 gl: GL;
31 gl2: boolean;
32 prog: WebGLProgram;
33 u: Record<string, WebGLUniformLocation | null>;
34 w: number;
35 h: number;
36 dpr: number;
37 reduced: boolean;
38}
39interface StageHooks {
40 setup?: (s: Stage) => void;
41 /** Set uniforms; return true to keep animating. */
42 frame: (s: Stage, time: number, dt: number) => boolean;
43}
44
45function createStage(canvas: HTMLCanvasElement, frag: string): Stage | null {
46 const attrs: WebGLContextAttributes = { antialias: false, alpha: true, premultipliedAlpha: true, powerPreference: "high-performance" };
47 let gl: GL | null = canvas.getContext("webgl2", attrs) as WebGL2RenderingContext | null;
48 const gl2 = !!gl;
49 if (!gl) gl = (canvas.getContext("webgl", attrs) || canvas.getContext("experimental-webgl", attrs)) as WebGLRenderingContext | null;
50 if (!gl) return null;
51 const head = gl2 ? "#version 300 es\n" : "";
52 const vs = head + (gl2 ? "#define attribute in\n" : "") + "attribute vec2 aP;void main(){gl_Position=vec4(aP,0.,1.);}";
53 const fs =
54 head +
55 "#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\n" +
56 (gl2 ? "#define texture2D texture\n#define gl_FragColor fdOut\nout vec4 fdOut;\n" : "") +
57 frag;
58 const make = (type: number, src: string) => {
59 const s = gl!.createShader(type);
60 if (!s) return null;
61 gl!.shaderSource(s, src);
62 gl!.compileShader(s);
63 if (!gl!.getShaderParameter(s, gl!.COMPILE_STATUS)) {
64 console.warn("[fd-shader]", gl!.getShaderInfoLog(s));
65 return null;
66 }
67 return s;
68 };
69 const v = make(gl.VERTEX_SHADER, vs);
70 const f = make(gl.FRAGMENT_SHADER, fs);
71 const prog = gl.createProgram();
72 if (!v || !f || !prog) return null;
73 gl.attachShader(prog, v);
74 gl.attachShader(prog, f);
75 gl.bindAttribLocation(prog, 0, "aP");
76 gl.linkProgram(prog);
77 if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
78 gl.useProgram(prog);
79 const buf = gl.createBuffer();
80 gl.bindBuffer(gl.ARRAY_BUFFER, buf);
81 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
82 gl.enableVertexAttribArray(0);
83 gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
84 const u: Stage["u"] = {};
85 for (const m of frag.matchAll(/uniform\s+\w+\s+(\w+)/g)) u[m[1]] = gl.getUniformLocation(prog, m[1]);
86 return { gl, gl2, prog, u, w: 1, h: 1, dpr: 1, reduced: false };
87}
88
89function useStage(ref: RefObject<HTMLCanvasElement | null>, frag: string, hooks: MutableRefObject<StageHooks>, maxDpr = 2) {
90 const [failed, setFailed] = useState(false);
91 const kick = useRef<() => void>(() => {});
92 useEffect(() => {
93 const canvas = ref.current;
94 if (!canvas) return;
95 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
96 let stage: Stage | null = null;
97 let raf = 0;
98 let last = 0;
99 let time = 0;
100 let visible = true;
101 let alive = true;
102 const loop = (now: number) => {
103 raf = 0;
104 if (!stage || !alive) return;
105 const dt = last ? Math.min((now - last) / 1000, 0.05) : 1 / 60;
106 last = now;
107 if (!reduced) time += dt;
108 const more = hooks.current.frame(stage, time, dt);
109 stage.gl.drawArrays(stage.gl.TRIANGLES, 0, 3);
110 if (more && !reduced && visible && !document.hidden) raf = requestAnimationFrame(loop);
111 else last = 0;
112 };
113 const request = () => {
114 if (!raf && alive && stage) raf = requestAnimationFrame(loop);
115 };
116 const resize = () => {
117 if (!stage) return;
118 const r = canvas.getBoundingClientRect();
119 const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
120 const w = Math.max(1, Math.round(r.width * dpr));
121 const h = Math.max(1, Math.round(r.height * dpr));
122 if (canvas.width !== w || canvas.height !== h) {
123 canvas.width = w;
124 canvas.height = h;
125 }
126 stage.w = w;
127 stage.h = h;
128 stage.dpr = dpr;
129 stage.gl.viewport(0, 0, w, h);
130 request();
131 };
132 const init = () => {
133 stage = createStage(canvas, frag);
134 if (!stage) {
135 setFailed(true);
136 return;
137 }
138 stage.reduced = reduced;
139 hooks.current.setup?.(stage);
140 resize();
141 };
142 const onLost = (e: Event) => {
143 e.preventDefault();
144 cancelAnimationFrame(raf);
145 raf = 0;
146 stage = null;
147 };
148 const onRestored = () => init();
149 const onVis = () => !document.hidden && request();
150 canvas.addEventListener("webglcontextlost", onLost);
151 canvas.addEventListener("webglcontextrestored", onRestored);
152 document.addEventListener("visibilitychange", onVis);
153 const ro = new ResizeObserver(resize);
154 ro.observe(canvas);
155 const io = new IntersectionObserver(([e]) => {
156 visible = e.isIntersecting;
157 if (visible) request();
158 });
159 io.observe(canvas);
160 kick.current = request;
161 init();
162 return () => {
163 alive = false;
164 cancelAnimationFrame(raf);
165 ro.disconnect();
166 io.disconnect();
167 canvas.removeEventListener("webglcontextlost", onLost);
168 canvas.removeEventListener("webglcontextrestored", onRestored);
169 document.removeEventListener("visibilitychange", onVis);
170 kick.current = () => {};
171 };
172 }, [ref, frag, hooks, maxDpr]);
173 return { failed, kick };
174}
175
176function makeTexture(gl: GL, filter: number, wrap: number) {
177 const t = gl.createTexture();
178 gl.bindTexture(gl.TEXTURE_2D, t);
179 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
180 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
181 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
182 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
183 return t;
184}
185
186const hexToRgb = (hex: string): [number, number, number] => {
187 let h = hex.replace("#", "").trim();
188 if (h.length === 3) h = h.split("").map((c) => c + c).join("");
189 const n = parseInt(h.slice(0, 6), 16) || 0;
190 return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
191};
192
193/** 64×64 blue-noise threshold map (void-and-cluster, void-filling pass). Cached. */
194let blueNoiseCache: Uint8Array | null = null;
195function blueNoise(): Uint8Array {
196 if (blueNoiseCache) return blueNoiseCache;
197 const S = 64;
198 const N = S * S;
199 const energy = new Float32Array(N);
200 const rank = new Int32Array(N).fill(-1);
201 const R = 6;
202 const kern: number[] = [];
203 for (let y = -R; y <= R; y++) for (let x = -R; x <= R; x++) kern.push(x, y, Math.exp(-(x * x + y * y) / (2 * 1.9 * 1.9)));
204 let seed = 7;
205 for (let i = 0; i < N; i++) {
206 seed = (seed * 16807) % 2147483647;
207 energy[i] = (seed / 2147483647) * 1e-4;
208 }
209 for (let k = 0; k < N; k++) {
210 let best = 0;
211 let bv = Infinity;
212 for (let i = 0; i < N; i++) if (rank[i] < 0 && energy[i] < bv) { bv = energy[i]; best = i; }
213 rank[best] = k;
214 const bx = best % S;
215 const by = (best / S) | 0;
216 for (let j = 0; j < kern.length; j += 3) {
217 const x = (bx + kern[j] + S) % S;
218 const y = (by + kern[j + 1] + S) % S;
219 energy[y * S + x] += kern[j + 2];
220 }
221 }
222 const out = new Uint8Array(N);
223 for (let i = 0; i < N; i++) out[i] = Math.floor((rank[i] * 256) / N);
224 blueNoiseCache = out;
225 return out;
226}
227
228/* ───────────────────────── shader ───────────────────────── */
229
230const FRAG = /* glsl */ `
231uniform sampler2D uImg;
232uniform sampler2D uNoise;
233uniform vec2 uRes;
234uniform vec2 uImgSize;
235uniform float uPx;
236uniform float uAlgo;
237uniform vec3 uPal[4];
238uniform float uN;
239uniform float uContrast;
240uniform float uBright;
241uniform vec3 uLens;
242uniform float uRadius;
243uniform float uMode;
244
245float bayer2(vec2 a){ a = floor(a); return fract(a.x * .5 + a.y * a.y * .75); }
246float bayer4(vec2 a){ return bayer2(.5 * a) * .25 + bayer2(a); }
247float bayer8(vec2 a){ return bayer4(.5 * a) * .25 + bayer2(a); }
248
249float threshold(vec2 cell){
250 if (uAlgo < .5) return bayer2(cell) + .125;
251 if (uAlgo < 1.5) return bayer4(cell) + .03125;
252 if (uAlgo < 2.5) return bayer8(cell) + .0078125;
253 return texture2D(uNoise, (mod(cell, 64.) + .5) / 64.).r + .002;
254}
255
256vec2 cover(vec2 uv){
257 float ra = uRes.x / uRes.y;
258 float ia = uImgSize.x / uImgSize.y;
259 vec2 s = ra > ia ? vec2(1., ia / ra) : vec2(ra / ia, 1.);
260 return (uv - .5) * s + .5;
261}
262
263vec3 pal(float i){
264 vec3 c = uPal[0];
265 c = mix(c, uPal[1], step(.5, i));
266 c = mix(c, uPal[2], step(1.5, i));
267 c = mix(c, uPal[3], step(2.5, i));
268 return c;
269}
270
271void main(){
272 vec2 fc = gl_FragCoord.xy;
273 float d = length(fc - uLens.xy);
274 float lens = (1. - smoothstep(uRadius * .35, uRadius, d)) * uLens.z;
275
276 float cell = uPx;
277 if (uMode < .5) cell = max(1., floor(mix(uPx, max(1., uPx * .34), lens) + .5));
278 vec2 id = floor(fc / cell);
279 vec2 center = (id + .5) * cell;
280 vec3 src = texture2D(uImg, cover(center / uRes)).rgb;
281 float l = dot(src, vec3(.2126, .7152, .0722));
282 l = clamp((l - .5) * uContrast + .5 + uBright, 0., 1.);
283
284 float t = threshold(id);
285 float n = uN - 1.;
286 float v = l * n;
287 float idx = min(floor(v) + step(t, fract(v)), n);
288 vec3 col = pal(idx);
289
290 if (uMode > .5 && uMode < 1.5) {
291 // invert — the loupe edge is itself dithered
292 float inside = step(t, lens);
293 col = mix(col, pal(n - idx), inside);
294 } else if (uMode > 1.5 && uMode < 2.5) {
295 vec3 photo = texture2D(uImg, cover(fc / uRes)).rgb;
296 col = mix(col, photo, step(t, lens));
297 }
298 gl_FragColor = vec4(col, 1.);
299}
300`;
301
302/* ───────────────────────── component ───────────────────────── */
303
304export type DitherAlgorithm = "bayer2" | "bayer4" | "bayer8" | "blue-noise";
305
306export interface DitherImageProps {
307 src?: string;
308 draw?: (ctx: CanvasRenderingContext2D, w: number, h: number) => void;
309 /** Size of the procedural `draw` canvas. Default [900, 1200]. */
310 drawSize?: [number, number];
311 palette?: string[];
312 pixelSize?: number;
313 algorithm?: DitherAlgorithm;
314 contrast?: number;
315 brightness?: number;
316 lens?: "sharpen" | "invert" | "reveal" | "none";
317 lensRadius?: number;
318 alt?: string;
319 className?: string;
320 style?: CSSProperties;
321 children?: ReactNode;
322}
323
324const ALGOS: Record<DitherAlgorithm, number> = { bayer2: 0, bayer4: 1, bayer8: 2, "blue-noise": 3 };
325const MODES = { sharpen: 0, invert: 1, reveal: 2, none: 0 };
326
327export function DitherImage({
328 src,
329 draw,
330 drawSize = [900, 1200],
331 palette = ["#121212", "#F2EFE8"],
332 pixelSize = 3,
333 algorithm = "bayer8",
334 contrast = 1.15,
335 brightness = 0,
336 lens = "sharpen",
337 lensRadius = 140,
338 alt = "",
339 className,
340 style,
341 children,
342}: DitherImageProps) {
343 const rootRef = useRef<HTMLDivElement>(null);
344 const canvasRef = useRef<HTMLCanvasElement>(null);
345 const props = useRef({ palette, pixelSize, algorithm, contrast, brightness, lens, lensRadius });
346 props.current = { palette, pixelSize, algorithm, contrast, brightness, lens, lensRadius };
347 const source = useRef<{ el: TexImageSource | null; w: number; h: number; dirty: boolean }>({ el: null, w: 1, h: 1, dirty: false });
348 const pointer = useRef({ x: 0, y: 0, tx: 0, ty: 0, on: 0, ton: 0, seen: false });
349 const fallbackRef = useRef<HTMLCanvasElement>(null);
350 const [srcVersion, setSrcVersion] = useState(0);
351 const tex = useRef<{ img: WebGLTexture | null; noise: WebGLTexture | null }>({ img: null, noise: null });
352
353 const hooks = useRef<StageHooks>({
354 setup(s) {
355 const { gl } = s;
356 gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
357 tex.current.noise = makeTexture(gl, gl.NEAREST, gl.REPEAT);
358 gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, 64, 64, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, blueNoise());
359 tex.current.img = makeTexture(gl, gl.LINEAR, gl.CLAMP_TO_EDGE);
360 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([128, 128, 128, 255]));
361 source.current.dirty = !!source.current.el;
362 gl.uniform1i(s.u.uImg, 0);
363 gl.uniform1i(s.u.uNoise, 1);
364 },
365 frame(s, _t, dt) {
366 const { gl, u } = s;
367 const p = props.current;
368 const src = source.current;
369 gl.activeTexture(gl.TEXTURE1);
370 gl.bindTexture(gl.TEXTURE_2D, tex.current.noise);
371 gl.activeTexture(gl.TEXTURE0);
372 gl.bindTexture(gl.TEXTURE_2D, tex.current.img);
373 if (src.dirty && src.el) {
374 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
375 try {
376 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src.el);
377 } catch {
378 /* tainted image (no CORS) — keep placeholder */
379 }
380 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
381 src.dirty = false;
382 }
383 const pt = pointer.current;
384 const k = s.reduced ? 1 : 1 - Math.exp(-dt * 16);
385 const kOn = s.reduced ? 1 : 1 - Math.exp(-dt * 7);
386 pt.x += (pt.tx - pt.x) * k;
387 pt.y += (pt.ty - pt.y) * k;
388 pt.on += (pt.ton - pt.on) * kOn;
389 const pal = p.palette.slice(0, 4);
390 while (pal.length < 2) pal.push("#ffffff");
391 const flat: number[] = [];
392 for (let i = 0; i < 4; i++) flat.push(...hexToRgb(pal[Math.min(i, pal.length - 1)]));
393 gl.uniform2f(u.uRes, s.w, s.h);
394 gl.uniform2f(u.uImgSize, src.w, src.h);
395 gl.uniform1f(u.uPx, Math.max(1, Math.round(p.pixelSize * s.dpr)));
396 gl.uniform1f(u.uAlgo, ALGOS[p.algorithm] ?? 2);
397 gl.uniform3fv(u.uPal, new Float32Array(flat));
398 gl.uniform1f(u.uN, pal.length);
399 gl.uniform1f(u.uContrast, p.contrast);
400 gl.uniform1f(u.uBright, p.brightness);
401 gl.uniform3f(u.uLens, pt.x * s.dpr, s.h - pt.y * s.dpr, p.lens === "none" ? 0 : pt.on);
402 gl.uniform1f(u.uRadius, p.lensRadius * s.dpr);
403 gl.uniform1f(u.uMode, MODES[p.lens] ?? 0);
404 const settling = Math.abs(pt.tx - pt.x) + Math.abs(pt.ty - pt.y) > 0.3 || Math.abs(pt.ton - pt.on) > 0.004;
405 return settling;
406 },
407 });
408 const { failed, kick } = useStage(canvasRef, FRAG, hooks);
409
410 // load / draw source
411 useEffect(() => {
412 let cancelled = false;
413 if (src) {
414 const img = new Image();
415 img.crossOrigin = "anonymous";
416 img.decoding = "async";
417 img.onload = () => {
418 if (cancelled) return;
419 source.current = { el: img, w: img.naturalWidth, h: img.naturalHeight, dirty: true };
420 kick.current();
421 setSrcVersion((v) => v + 1);
422 };
423 img.src = src;
424 } else if (draw) {
425 const c = document.createElement("canvas");
426 c.width = drawSize[0];
427 c.height = drawSize[1];
428 const ctx = c.getContext("2d");
429 if (ctx) draw(ctx, c.width, c.height);
430 source.current = { el: c, w: c.width, h: c.height, dirty: true };
431 kick.current();
432 setSrcVersion((v) => v + 1);
433 }
434 return () => {
435 cancelled = true;
436 };
437 // eslint-disable-next-line react-hooks/exhaustive-deps
438 }, [src, draw, drawSize[0], drawSize[1], kick]);
439
440 // No WebGL: dither once on the CPU into a 2D canvas (ordered Bayer 8×8), so the fallback still shows the image.
441 useEffect(() => {
442 const out = fallbackRef.current;
443 const srcEl = source.current.el as CanvasImageSource | null;
444 if (!failed || !out || !srcEl) return;
445 const r = out.getBoundingClientRect();
446 const cell = Math.max(1, Math.round(pixelSize));
447 const W = Math.max(1, Math.round(r.width / cell));
448 const H = Math.max(1, Math.round(r.height / cell));
449 out.width = W;
450 out.height = H;
451 const ctx = out.getContext("2d");
452 if (!ctx) return;
453 const { w, h } = source.current;
454 const k = Math.max(W / w, H / h);
455 try {
456 ctx.drawImage(srcEl, (W - w * k) / 2, (H - h * k) / 2, w * k, h * k);
457 const img = ctx.getImageData(0, 0, W, H);
458 const pal = palette.slice(0, 4).map(hexToRgb);
459 const n = pal.length - 1;
460 const base = [0, 2, 3, 1];
461 for (let y = 0; y < H; y++)
462 for (let x = 0; x < W; x++) {
463 let b = 0;
464 for (let q = 0; q < 3; q++) b += base[(((y >> q) & 1) << 1) | ((x >> q) & 1)] * 4 ** (2 - q);
465 const t = (b + 0.5) / 64;
466 const i = (y * W + x) * 4;
467 let l = (0.2126 * img.data[i] + 0.7152 * img.data[i + 1] + 0.0722 * img.data[i + 2]) / 255;
468 l = Math.min(1, Math.max(0, (l - 0.5) * contrast + 0.5 + brightness));
469 const v = l * n;
470 const idx = Math.min(n, Math.floor(v) + (v - Math.floor(v) > t ? 1 : 0));
471 img.data[i] = pal[idx][0] * 255;
472 img.data[i + 1] = pal[idx][1] * 255;
473 img.data[i + 2] = pal[idx][2] * 255;
474 img.data[i + 3] = 255;
475 }
476 ctx.putImageData(img, 0, 0);
477 } catch {
478 /* tainted canvas — CSS halftone shows through */
479 }
480 }, [failed, srcVersion, palette.join(), pixelSize, contrast, brightness]);
481
482 // redraw when visual props change
483 useEffect(() => kick.current(), [palette.join(), pixelSize, algorithm, contrast, brightness, lens, lensRadius, kick]);
484
485 // pointer → loupe
486 useEffect(() => {
487 const el = rootRef.current;
488 if (!el) return;
489 const move = (e: PointerEvent) => {
490 const r = el.getBoundingClientRect();
491 const pt = pointer.current;
492 pt.tx = e.clientX - r.left;
493 pt.ty = e.clientY - r.top;
494 if (!pt.seen) {
495 pt.x = pt.tx;
496 pt.y = pt.ty;
497 pt.seen = true;
498 }
499 pt.ton = 1;
500 kick.current();
501 };
502 const leave = () => {
503 pointer.current.ton = 0;
504 pointer.current.seen = false;
505 kick.current();
506 };
507 el.addEventListener("pointermove", move);
508 el.addEventListener("pointerdown", move);
509 el.addEventListener("pointerleave", leave);
510 el.addEventListener("pointercancel", leave);
511 return () => {
512 el.removeEventListener("pointermove", move);
513 el.removeEventListener("pointerdown", move);
514 el.removeEventListener("pointerleave", leave);
515 el.removeEventListener("pointercancel", leave);
516 };
517 }, [kick]);
518
519 return (
520 <div ref={rootRef} className={`fd-dimg${className ? " " + className : ""}`} style={style}>
521 <style>{css}</style>
522 {failed ? (
523 <>
524 <div className="fd-dimg-fallback" aria-hidden="true" />
525 <canvas ref={fallbackRef} className="fd-dimg-canvas" role={alt ? "img" : undefined} aria-label={alt || undefined} aria-hidden={alt ? undefined : true} />
526 </>
527 ) : (
528 <canvas ref={canvasRef} className="fd-dimg-canvas" role={alt ? "img" : undefined} aria-label={alt || undefined} aria-hidden={alt ? undefined : true} />
529 )}
530 {children ? <div className="fd-dimg-overlay">{children}</div> : null}
531 </div>
532 );
533}
534
535const css = `
536.fd-dimg, .fd-dimg *, .fd-dimg *::before, .fd-dimg *::after { box-sizing: border-box; }
537.fd-dimg {
538 position: relative; display: block; margin: 0; padding: 0; border: 0;
539 overflow: hidden; border-radius: var(--fd-dimg-radius, 0px);
540 background: #121212; color: inherit; touch-action: pan-y;
541 isolation: isolate;
542}
543.fd-dimg-canvas {
544 position: absolute; inset: 0; display: block; width: 100%; height: 100%;
545 margin: 0; padding: 0; border: 0; image-rendering: pixelated;
546}
547.fd-dimg-fallback {
548 position: absolute; inset: 0; margin: 0; padding: 0; border: 0;
549 background: var(--fd-dimg-fallback, radial-gradient(circle at 50% 38%, #F2EFE8 0 18%, #8A857D 42%, #121212 78%));
550 -webkit-mask: radial-gradient(circle, #000 1.1px, transparent 1.6px) 0 0 / 4px 4px;
551 mask: radial-gradient(circle, #000 1.1px, transparent 1.6px) 0 0 / 4px 4px;
552}
553.fd-dimg-overlay { position: absolute; inset: 0; margin: 0; padding: 0; pointer-events: none; }
554.fd-dimg-overlay a, .fd-dimg-overlay button { pointer-events: auto; }
555`;
556
557/* ───────────────────────── demo ───────────────────────── */
558
559/** Procedural editorial portrait: side-lit figure in a wide-brim hat against a halo. */
560export function drawPortrait(ctx: CanvasRenderingContext2D, w: number, h: number) {
561 const s = w / 900;
562 ctx.save();
563 ctx.scale(s, s);
564 const W = 900;
565 const H = h / s;
566 // backdrop
567 const bg = ctx.createLinearGradient(0, 0, 0, H);
568 bg.addColorStop(0, "#fbf7ef");
569 bg.addColorStop(0.3, "#e6dccd");
570 bg.addColorStop(0.62, "#a89a88");
571 bg.addColorStop(1, "#4a4038");
572 ctx.fillStyle = bg;
573 ctx.fillRect(0, 0, W, H);
574 const halo = ctx.createRadialGradient(560, 430, 20, 560, 430, 420);
575 halo.addColorStop(0, "rgba(255,250,240,1)");
576 halo.addColorStop(0.45, "rgba(255,246,230,.55)");
577 halo.addColorStop(1, "rgba(255,246,230,0)");
578 ctx.fillStyle = halo;
579 ctx.fillRect(0, 0, W, H);
580
581 // coat / shoulders
582 const coat = ctx.createLinearGradient(120, 0, 820, 0);
583 coat.addColorStop(0, "#3a332d");
584 coat.addColorStop(0.35, "#1d1916");
585 coat.addColorStop(1, "#060505");
586 ctx.fillStyle = coat;
587 ctx.beginPath();
588 ctx.moveTo(40, H);
589 ctx.bezierCurveTo(60, 980, 180, 860, 330, 820);
590 ctx.bezierCurveTo(390, 805, 420, 770, 430, 740);
591 ctx.lineTo(560, 740);
592 ctx.bezierCurveTo(580, 790, 640, 815, 720, 840);
593 ctx.bezierCurveTo(840, 880, 880, 1000, 900, H);
594 ctx.closePath();
595 ctx.fill();
596 // lapel highlight
597 ctx.strokeStyle = "rgba(255,240,220,.18)";
598 ctx.lineWidth = 6;
599 ctx.beginPath();
600 ctx.moveTo(430, 760);
601 ctx.bezierCurveTo(440, 900, 470, 1010, 500, H);
602 ctx.stroke();
603
604 // neck
605 const neck = ctx.createLinearGradient(420, 0, 580, 0);
606 neck.addColorStop(0, "#e8c9a8");
607 neck.addColorStop(0.6, "#9c7458");
608 neck.addColorStop(1, "#4b3326");
609 ctx.fillStyle = neck;
610 ctx.beginPath();
611 ctx.moveTo(440, 600);
612 ctx.lineTo(560, 600);
613 ctx.bezierCurveTo(560, 680, 575, 740, 585, 770);
614 ctx.bezierCurveTo(530, 800, 470, 800, 425, 765);
615 ctx.bezierCurveTo(440, 720, 445, 670, 440, 600);
616 ctx.fill();
617
618 // face
619 ctx.save();
620 ctx.translate(500, 500);
621 ctx.rotate(-0.08);
622 const face = ctx.createRadialGradient(-60, -40, 10, -10, 10, 190);
623 face.addColorStop(0, "#fff1e2");
624 face.addColorStop(0.35, "#e9c7a5");
625 face.addColorStop(0.75, "#a3775a");
626 face.addColorStop(1, "#4a3022");
627 ctx.fillStyle = face;
628 ctx.beginPath();
629 ctx.moveTo(0, -165);
630 ctx.bezierCurveTo(105, -165, 130, -60, 122, 10);
631 ctx.bezierCurveTo(112, 100, 60, 165, 0, 170);
632 ctx.bezierCurveTo(-60, 165, -110, 100, -120, 10);
633 ctx.bezierCurveTo(-128, -60, -105, -165, 0, -165);
634 ctx.fill();
635 // sunglasses
636 ctx.fillStyle = "#0b0a09";
637 const lensShape = (x: number) => {
638 ctx.beginPath();
639 ctx.moveTo(x - 52, -22);
640 ctx.bezierCurveTo(x - 52, -40, x + 52, -40, x + 52, -22);
641 ctx.bezierCurveTo(x + 52, 20, x - 52, 26, x - 52, -22);
642 ctx.fill();
643 };
644 lensShape(-52);
645 lensShape(54);
646 ctx.fillRect(-6, -30, 14, 8);
647 ctx.fillStyle = "rgba(255,255,255,.55)";
648 ctx.beginPath();
649 ctx.ellipse(-74, -18, 16, 6, -0.4, 0, Math.PI * 2);
650 ctx.fill();
651 // nose shadow
652 ctx.fillStyle = "rgba(70,40,25,.35)";
653 ctx.beginPath();
654 ctx.moveTo(12, 0);
655 ctx.bezierCurveTo(22, 40, 26, 62, 10, 72);
656 ctx.lineTo(0, 66);
657 ctx.closePath();
658 ctx.fill();
659 // lips
660 ctx.fillStyle = "#5a1a14";
661 ctx.beginPath();
662 ctx.moveTo(-38, 108);
663 ctx.bezierCurveTo(-18, 94, 18, 94, 38, 106);
664 ctx.bezierCurveTo(18, 128, -18, 128, -38, 108);
665 ctx.fill();
666 // jaw shadow
667 const jaw = ctx.createLinearGradient(0, 60, 0, 180);
668 jaw.addColorStop(0, "rgba(40,24,16,0)");
669 jaw.addColorStop(1, "rgba(40,24,16,.45)");
670 ctx.fillStyle = jaw;
671 ctx.fillRect(-130, 60, 260, 120);
672 ctx.restore();
673
674 // hair at sides
675 ctx.fillStyle = "#120e0b";
676 ctx.beginPath();
677 ctx.moveTo(372, 400);
678 ctx.bezierCurveTo(350, 520, 360, 640, 400, 700);
679 ctx.bezierCurveTo(395, 600, 392, 500, 400, 420);
680 ctx.closePath();
681 ctx.fill();
682 ctx.beginPath();
683 ctx.moveTo(628, 390);
684 ctx.bezierCurveTo(660, 520, 650, 650, 600, 720);
685 ctx.bezierCurveTo(612, 600, 616, 500, 606, 410);
686 ctx.closePath();
687 ctx.fill();
688
689 // hat: crown + brim
690 const brim = ctx.createLinearGradient(80, 300, 900, 420);
691 brim.addColorStop(0, "#4b4038");
692 brim.addColorStop(0.4, "#1e1915");
693 brim.addColorStop(1, "#050404");
694 ctx.fillStyle = brim;
695 ctx.beginPath();
696 ctx.ellipse(500, 360, 400, 92, -0.1, 0, Math.PI * 2);
697 ctx.fill();
698 const crown = ctx.createLinearGradient(330, 0, 680, 0);
699 crown.addColorStop(0, "#5d5148");
700 crown.addColorStop(0.45, "#26201b");
701 crown.addColorStop(1, "#070606");
702 ctx.fillStyle = crown;
703 ctx.beginPath();
704 ctx.moveTo(340, 360);
705 ctx.bezierCurveTo(330, 230, 380, 150, 500, 150);
706 ctx.bezierCurveTo(620, 150, 670, 230, 660, 340);
707 ctx.bezierCurveTo(560, 380, 440, 385, 340, 360);
708 ctx.fill();
709 // hat band
710 ctx.fillStyle = "#7d2a1d";
711 ctx.beginPath();
712 ctx.moveTo(338, 330);
713 ctx.bezierCurveTo(440, 352, 560, 350, 662, 312);
714 ctx.lineTo(660, 342);
715 ctx.bezierCurveTo(560, 380, 440, 385, 340, 360);
716 ctx.closePath();
717 ctx.fill();
718 // brim shadow on face
719 const shade = ctx.createLinearGradient(0, 380, 0, 470);
720 shade.addColorStop(0, "rgba(20,14,10,.75)");
721 shade.addColorStop(1, "rgba(20,14,10,0)");
722 ctx.fillStyle = shade;
723 ctx.fillRect(380, 380, 260, 100);
724 ctx.restore();
725}
726
727export function Demo() {
728 return (
729 <section className="fd-dimg-demo">
730 <style>{demoCss}</style>
731 <div className="fd-dimg-demo-copy">
732 <p className="fd-dimg-demo-kicker">Vesper Nº 14 — The Winter Issue</p>
733 <h2 className="fd-dimg-demo-title">Shot in shadow. Printed in dots.</h2>
734 <p className="fd-dimg-demo-body">
735 Forty-two looks in wool, felt and charcoal, photographed by Mara Lindqvist and set in a three-ink risograph palette.
736 Move across the cover to bring the shoot into focus.
737 </p>
738 <div className="fd-dimg-demo-row">
739 <a className="fd-dimg-demo-cta" href="#subscribe" onClick={(e) => e.preventDefault()}>
740 Subscribe — 4 issues €48
741 </a>
742 <span className="fd-dimg-demo-meta">On newsstands 12 Nov 2026</span>
743 </div>
744 </div>
745 <DitherImage
746 className="fd-dimg-demo-cover"
747 draw={drawPortrait}
748 palette={["#15120F", "#A8402A", "#EFE7D8"]}
749 pixelSize={3}
750 algorithm="bayer8"
751 contrast={1.25}
752 lensRadius={130}
753 alt="Vesper magazine cover: a model in a wide-brim felt hat and dark sunglasses"
754 >
755 <div className="fd-dimg-demo-mast">VESPER</div>
756 <div className="fd-dimg-demo-issue">
757 <span>Nº 14</span>
758 <span>Winter 2026</span>
759 <span>€14 · £12</span>
760 </div>
761 <div className="fd-dimg-demo-lines">
762 <p className="fd-dimg-demo-line-big">The Quiet Season</p>
763 <p className="fd-dimg-demo-line">42 looks in wool, felt &amp; shadow</p>
764 <p className="fd-dimg-demo-line fd-dimg-demo-line-sep">Ines Okafor on building Atelier Nord from a Lagos rooftop</p>
765 </div>
766 <div className="fd-dimg-demo-side">Paris Couture,<br />decoded</div>
767 </DitherImage>
768 </section>
769 );
770}
771
772const demoCss = `
773.fd-dimg-demo, .fd-dimg-demo *, .fd-dimg-demo *::before, .fd-dimg-demo *::after { box-sizing: border-box; }
774.fd-dimg-demo {
775 --fd-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
776 --fd-dur-1: 120ms;
777 min-height: 100vh; margin: 0; padding: clamp(28px, 6vw, 88px);
778 display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); gap: clamp(32px, 6vw, 96px);
779 align-items: center; background: #ECE5D8; color: #15120F;
780 font: 400 15px/1.5 ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
781}
782.fd-dimg-demo-copy { max-width: 460px; justify-self: end; margin: 0; padding: 0; }
783.fd-dimg-demo-kicker {
784 margin: 0 0 20px; padding: 0; color: #A8402A; letter-spacing: 0.12em; text-transform: uppercase;
785 font: 600 12px/1.4 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
786}
787.fd-dimg-demo-title {
788 margin: 0 0 20px; padding: 0; color: #15120F; letter-spacing: -0.04em;
789 font: 700 clamp(2.75rem, 5.4vw, 5.5rem)/0.95 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", "Segoe UI", Roboto, sans-serif;
790}
791.fd-dimg-demo-body { margin: 0 0 32px; padding: 0; color: #4A433B; font: 400 1.0625rem/1.55 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em; max-width: 40ch; }
792.fd-dimg-demo-row { display: flex; flex-wrap: wrap; align-items: center; gap: 16px 24px; margin: 0; padding: 0; }
793.fd-dimg-demo-cta {
794 display: inline-flex; align-items: center; height: 48px; margin: 0; padding: 0 24px; border: 0; border-radius: 9999px;
795 background: #15120F; color: #EFE7D8; text-decoration: none;
796 font: 600 0.9375rem/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em;
797 transition: transform var(--fd-dur-1) var(--fd-ease-out), background var(--fd-dur-1) var(--fd-ease-out);
798}
799.fd-dimg-demo-cta:hover { background: #A8402A; }
800.fd-dimg-demo-cta:active { transform: scale(0.96); }
801.fd-dimg-demo-cta:focus-visible { outline: 2px solid rgba(168, 64, 42, .7); outline-offset: 2px; }
802.fd-dimg-demo-meta { margin: 0; padding: 0; color: #6B6258; font: 500 13px/1.4 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; }
803.fd-dimg-demo-cover.fd-dimg {
804 width: min(100%, 560px); aspect-ratio: 3 / 4; justify-self: start; container-type: inline-size;
805 --fd-dimg-radius: 4px;
806 box-shadow: 0 1px 0 rgba(0,0,0,.06), 0 30px 60px -20px rgba(40, 28, 16, .45), 0 60px 120px -40px rgba(40, 28, 16, .35);
807 cursor: crosshair;
808}
809.fd-dimg-demo-mast {
810 position: absolute; left: 5%; right: 5%; top: 3.2%; margin: 0; padding: 0;
811 color: #15120F; text-align: center; letter-spacing: -0.06em;
812 font: 800 clamp(3.5rem, 11vw, 9.4rem)/0.82 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", "Segoe UI", Roboto, sans-serif;
813 font-size: 25.5cqw;
814}
815.fd-dimg-demo-issue {
816 position: absolute; left: 5%; right: 5%; top: 1.4%; display: flex; justify-content: space-between; margin: 0; padding: 0;
817 color: #15120F; font: 600 10px/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: 0.14em; text-transform: uppercase;
818}
819.fd-dimg-demo-lines { position: absolute; left: 5%; bottom: 5%; max-width: 58%; margin: 0; padding: 0; }
820.fd-dimg-demo-line-big {
821 margin: 0 0 6px; padding: 0; color: #EFE7D8; letter-spacing: -0.035em;
822 font: 700 clamp(1.75rem, 3.6vw, 2.75rem)/0.95 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", "Segoe UI", Roboto, sans-serif;
823 font-size: clamp(1.5rem, 8cqw, 2.75rem);
824}
825.fd-dimg-demo-line { margin: 0; padding: 0; color: #EFE7D8; font: 500 0.8125rem/1.35 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: 0; }
826.fd-dimg-demo-line-sep { margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(239, 231, 216, .5); max-width: 26ch; }
827.fd-dimg-demo-side {
828 position: absolute; right: 5%; bottom: 5%; max-width: 32%; margin: 0; padding: 6px 10px; background: #A8402A; color: #EFE7D8; text-align: right;
829 font: 700 0.8125rem/1.15 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: 0.02em; text-transform: uppercase;
830}
831@media (max-width: 820px) {
832 .fd-dimg-demo { grid-template-columns: 1fr; padding: 28px 20px 40px; }
833 .fd-dimg-demo-copy { justify-self: start; }
834 .fd-dimg-demo-cover.fd-dimg { justify-self: center; }
835}
836`;

More Dither. Same file contract.

Dither Logo

Your mark as ordered-dither stipple dots — each one a spring particle that scatters from the cursor and settles back.

Dither Gradient

A slow, flowing mesh gradient quantised through Bayer dithering into 2–4 inks — a hero backdrop with a light pool that follows the cursor.

Dither Text

Headlines printed as a Bayer halftone — dots ink in as the text scrolls into view and dissolve as it leaves.