Dither Logo
Your mark as ordered-dither stipple dots — each one a spring particle that scatters from the cursor and settles back.
Bayer and blue-noise dithering of any image into a 2–4 ink palette, with a cursor loupe that sharpens, inverts or reveals.
Browser support · WebGL2 with WebGL1 fallback. Without WebGL a CSS halftone gradient is shown.
npx shadcn@latest add https://freedesignmd.com/r/dither-image.json1/**2 * DitherImage — GPU ordered / blue-noise dithering of any image into a 2–43 * colour palette, with a cursor "loupe" that sharpens, inverts or reveals the4 * photo underneath.5 *6 * Props7 * - 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 halftone20 * 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 dithered292 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 source411 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-deps438 }, [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 change483 useEffect(() => kick.current(), [palette.join(), pixelSize, algorithm, contrast, brightness, lens, lensRadius, kick]);484 485 // pointer → loupe486 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 // backdrop567 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 / shoulders582 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 highlight597 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 // neck605 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 // face619 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 // sunglasses636 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 shadow652 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 // lips660 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 shadow667 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 sides675 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 + brim690 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 band710 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 face719 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 €48741 </a>742 <span className="fd-dimg-demo-meta">On newsstands 12 Nov 2026</span>743 </div>744 </div>745 <DitherImage746 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 & 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`;Your mark as ordered-dither stipple dots — each one a spring particle that scatters from the cursor and settles back.
A slow, flowing mesh gradient quantised through Bayer dithering into 2–4 inks — a hero backdrop with a light pool that follows the cursor.
Headlines printed as a Bayer halftone — dots ink in as the text scrolls into view and dissolve as it leaves.