Dither Text

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

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

Browser support · WebGL2 / WebGL1. Without WebGL the real text gets a dotted CSS mask.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/dither-text.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/dither/dither-text.tsx
1/**
2 * DitherText — a headline printed as an ordered-dither halftone. The text is
3 * rasterised to a mask, then a shader fills it with Bayer-dithered tone whose
4 * density follows scroll: dots build up as the headline scrolls into view
5 * and dissolve as it leaves. A soft light gradient across the letters gives
6 * the dots a printed, tonal look.
7 *
8 * Props
9 * - children: string The headline (also real DOM text for a11y/SEO/selection).
10 * - as?: "h1" | "h2" | "p". Default "h2".
11 * - ink?: string Dot colour. Default "#0B0B0F".
12 * - pixelSize?: number Dither cell in CSS px. Default 3.
13 * - scroll?: boolean Scroll-reactive density. Default true. (false = fully inked)
14 * - tone?: [number, number] Min/max density across the letters. Default [0.35, 1].
15 * - angle?: number Direction of the tonal gradient in degrees. Default 120.
16 * CSS: size with `font-size` on the root (clamp() works); the canvas matches the DOM text.
17 *
18 * Browser support: WebGL2 / WebGL1. Without WebGL the DOM text is shown with a
19 * dotted CSS mask. Reduced motion: density is fixed at full (no scroll link).
20 */
21import { useEffect, useRef, useState } from "react";
22import type { CSSProperties, MutableRefObject, RefObject } from "react";
23
24/* ───────────────────────── tiny WebGL stage ───────────────────────── */
25
26type GL = WebGLRenderingContext | WebGL2RenderingContext;
27interface Stage {
28 gl: GL;
29 gl2: boolean;
30 prog: WebGLProgram;
31 u: Record<string, WebGLUniformLocation | null>;
32 w: number;
33 h: number;
34 dpr: number;
35 reduced: boolean;
36}
37interface StageHooks {
38 setup?: (s: Stage) => void;
39 /** Set uniforms; return true to keep animating. */
40 frame: (s: Stage, time: number, dt: number) => boolean;
41}
42
43function createStage(canvas: HTMLCanvasElement, frag: string): Stage | null {
44 const attrs: WebGLContextAttributes = { antialias: false, alpha: true, premultipliedAlpha: true, powerPreference: "high-performance" };
45 let gl: GL | null = canvas.getContext("webgl2", attrs) as WebGL2RenderingContext | null;
46 const gl2 = !!gl;
47 if (!gl) gl = (canvas.getContext("webgl", attrs) || canvas.getContext("experimental-webgl", attrs)) as WebGLRenderingContext | null;
48 if (!gl) return null;
49 const head = gl2 ? "#version 300 es\n" : "";
50 const vs = head + (gl2 ? "#define attribute in\n" : "") + "attribute vec2 aP;void main(){gl_Position=vec4(aP,0.,1.);}";
51 const fs =
52 head +
53 "#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\n" +
54 (gl2 ? "#define texture2D texture\n#define gl_FragColor fdOut\nout vec4 fdOut;\n" : "") +
55 frag;
56 const make = (type: number, src: string) => {
57 const s = gl!.createShader(type);
58 if (!s) return null;
59 gl!.shaderSource(s, src);
60 gl!.compileShader(s);
61 if (!gl!.getShaderParameter(s, gl!.COMPILE_STATUS)) {
62 console.warn("[fd-shader]", gl!.getShaderInfoLog(s));
63 return null;
64 }
65 return s;
66 };
67 const v = make(gl.VERTEX_SHADER, vs);
68 const f = make(gl.FRAGMENT_SHADER, fs);
69 const prog = gl.createProgram();
70 if (!v || !f || !prog) return null;
71 gl.attachShader(prog, v);
72 gl.attachShader(prog, f);
73 gl.bindAttribLocation(prog, 0, "aP");
74 gl.linkProgram(prog);
75 if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;
76 gl.useProgram(prog);
77 const buf = gl.createBuffer();
78 gl.bindBuffer(gl.ARRAY_BUFFER, buf);
79 gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
80 gl.enableVertexAttribArray(0);
81 gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
82 const u: Stage["u"] = {};
83 for (const m of frag.matchAll(/uniform\s+\w+\s+(\w+)/g)) u[m[1]] = gl.getUniformLocation(prog, m[1]);
84 return { gl, gl2, prog, u, w: 1, h: 1, dpr: 1, reduced: false };
85}
86
87function useStage(ref: RefObject<HTMLCanvasElement | null>, frag: string, hooks: MutableRefObject<StageHooks>, maxDpr = 2) {
88 const [failed, setFailed] = useState(false);
89 const kick = useRef<() => void>(() => {});
90 useEffect(() => {
91 const canvas = ref.current;
92 if (!canvas) return;
93 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
94 let stage: Stage | null = null;
95 let raf = 0;
96 let last = 0;
97 let time = 0;
98 let visible = true;
99 let alive = true;
100 const loop = (now: number) => {
101 raf = 0;
102 if (!stage || !alive) return;
103 const dt = last ? Math.min((now - last) / 1000, 0.05) : 1 / 60;
104 last = now;
105 if (!reduced) time += dt;
106 const more = hooks.current.frame(stage, time, dt);
107 stage.gl.drawArrays(stage.gl.TRIANGLES, 0, 3);
108 if (more && !reduced && visible && !document.hidden) raf = requestAnimationFrame(loop);
109 else last = 0;
110 };
111 const request = () => {
112 if (!raf && alive && stage) raf = requestAnimationFrame(loop);
113 };
114 const resize = () => {
115 if (!stage) return;
116 const r = canvas.getBoundingClientRect();
117 const dpr = Math.min(window.devicePixelRatio || 1, maxDpr);
118 const w = Math.max(1, Math.round(r.width * dpr));
119 const h = Math.max(1, Math.round(r.height * dpr));
120 if (canvas.width !== w || canvas.height !== h) {
121 canvas.width = w;
122 canvas.height = h;
123 }
124 stage.w = w;
125 stage.h = h;
126 stage.dpr = dpr;
127 stage.gl.viewport(0, 0, w, h);
128 request();
129 };
130 const init = () => {
131 stage = createStage(canvas, frag);
132 if (!stage) {
133 setFailed(true);
134 return;
135 }
136 stage.reduced = reduced;
137 hooks.current.setup?.(stage);
138 resize();
139 };
140 const onLost = (e: Event) => {
141 e.preventDefault();
142 cancelAnimationFrame(raf);
143 raf = 0;
144 stage = null;
145 };
146 const onRestored = () => init();
147 const onVis = () => !document.hidden && request();
148 canvas.addEventListener("webglcontextlost", onLost);
149 canvas.addEventListener("webglcontextrestored", onRestored);
150 document.addEventListener("visibilitychange", onVis);
151 const ro = new ResizeObserver(resize);
152 ro.observe(canvas);
153 const io = new IntersectionObserver(([e]) => {
154 visible = e.isIntersecting;
155 if (visible) request();
156 });
157 io.observe(canvas);
158 kick.current = request;
159 init();
160 return () => {
161 alive = false;
162 cancelAnimationFrame(raf);
163 ro.disconnect();
164 io.disconnect();
165 canvas.removeEventListener("webglcontextlost", onLost);
166 canvas.removeEventListener("webglcontextrestored", onRestored);
167 document.removeEventListener("visibilitychange", onVis);
168 kick.current = () => {};
169 };
170 }, [ref, frag, hooks, maxDpr]);
171 return { failed, kick };
172}
173
174function makeTexture(gl: GL, filter: number, wrap: number) {
175 const t = gl.createTexture();
176 gl.bindTexture(gl.TEXTURE_2D, t);
177 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
178 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
179 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap);
180 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap);
181 return t;
182}
183
184const hexToRgb = (hex: string): [number, number, number] => {
185 let h = hex.replace("#", "").trim();
186 if (h.length === 3) h = h.split("").map((c) => c + c).join("");
187 const n = parseInt(h.slice(0, 6), 16) || 0;
188 return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
189};
190
191/* ───────────────────────── shader ───────────────────────── */
192
193const FRAG = /* glsl */ `
194uniform sampler2D uMask;
195uniform vec2 uRes;
196uniform float uPx;
197uniform vec3 uInk;
198uniform float uDensity; // 0..1 from scroll
199uniform vec2 uTone;
200uniform vec2 uDir;
201
202float bayer2(vec2 a){ a = floor(a); return fract(a.x * .5 + a.y * a.y * .75); }
203float bayer4(vec2 a){ return bayer2(.5 * a) * .25 + bayer2(a); }
204float bayer8(vec2 a){ return bayer4(.5 * a) * .25 + bayer2(a); }
205
206void main(){
207 vec2 id = floor(gl_FragCoord.xy / uPx);
208 vec2 c = (id + .5) * uPx / uRes;
209 float m = texture2D(uMask, c).r;
210 if (m < .5) { gl_FragColor = vec4(0.); return; }
211 // tonal sweep across the line + a sweep that "prints" with scroll
212 float g = dot(c - .5, uDir) + .5;
213 float tone = mix(uTone.x, uTone.y, smoothstep(0., 1., g));
214 float wipe = smoothstep(uDensity * 1.6 - .6, uDensity * 1.6 - .2, 1. - g);
215 float l = tone * (1. - wipe) * smoothstep(0., .25, uDensity);
216 float th = bayer8(id) + .0078125;
217 float on = step(th, l);
218 // round dots inside each cell
219 vec2 f = fract(gl_FragCoord.xy / uPx) - .5;
220 float dot_ = 1. - smoothstep(.36, .5, length(f));
221 float a = on * dot_;
222 gl_FragColor = vec4(uInk * a, a);
223}
224`;
225
226/* ───────────────────────── component ───────────────────────── */
227
228export interface DitherTextProps {
229 children: string;
230 as?: "h1" | "h2" | "p";
231 ink?: string;
232 pixelSize?: number;
233 scroll?: boolean;
234 tone?: [number, number];
235 angle?: number;
236 className?: string;
237 style?: CSSProperties;
238}
239
240export function DitherText({
241 children,
242 as: Tag = "h2",
243 ink = "#0B0B0F",
244 pixelSize = 3,
245 scroll = true,
246 tone = [0.35, 1],
247 angle = 120,
248 className,
249 style,
250}: DitherTextProps) {
251 const rootRef = useRef<HTMLDivElement>(null);
252 const textRef = useRef<HTMLElement>(null);
253 const canvasRef = useRef<HTMLCanvasElement>(null);
254 const [ready, setReady] = useState(false);
255 const st = useRef({ ink, pixelSize, tone, angle, scroll, density: scroll ? 0 : 1, target: scroll ? 0 : 1, mask: null as HTMLCanvasElement | null, dirty: false, tex: null as WebGLTexture | null });
256 Object.assign(st.current, { ink, pixelSize, tone, angle, scroll });
257
258 const hooks = useRef<StageHooks>({
259 setup(s) {
260 const { gl } = s;
261 st.current.tex = makeTexture(gl, gl.NEAREST, gl.CLAMP_TO_EDGE);
262 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4));
263 gl.enable(gl.BLEND);
264 gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
265 st.current.dirty = true;
266 },
267 frame(s, _t, dt) {
268 const { gl, u } = s;
269 const S = st.current;
270 gl.clearColor(0, 0, 0, 0);
271 gl.clear(gl.COLOR_BUFFER_BIT);
272 gl.activeTexture(gl.TEXTURE0);
273 gl.bindTexture(gl.TEXTURE_2D, S.tex);
274 if (S.dirty && S.mask && S.mask.width === s.w && S.mask.height === s.h) {
275 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
276 gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, S.mask);
277 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
278 S.dirty = false;
279 }
280 const target = s.reduced || !S.scroll ? 1 : S.target;
281 S.density += (target - S.density) * (s.reduced ? 1 : 1 - Math.exp(-dt * 6));
282 const a = (S.angle * Math.PI) / 180;
283 gl.uniform1i(u.uMask, 0);
284 gl.uniform2f(u.uRes, s.w, s.h);
285 gl.uniform1f(u.uPx, Math.max(1, Math.round(S.pixelSize * s.dpr)));
286 gl.uniform3fv(u.uInk, hexToRgb(S.ink));
287 gl.uniform1f(u.uDensity, S.density);
288 gl.uniform2f(u.uTone, S.tone[0], S.tone[1]);
289 gl.uniform2f(u.uDir, Math.cos(a), Math.sin(a));
290 return Math.abs(target - S.density) > 0.002;
291 },
292 });
293 const { failed, kick } = useStage(canvasRef, FRAG, hooks);
294 useEffect(() => kick.current(), [ink, pixelSize, tone[0], tone[1], angle, kick]);
295
296 // mask from DOM text layout
297 useEffect(() => {
298 const textEl = textRef.current;
299 if (!textEl || failed) return;
300 let raf = 0;
301 const build = () => {
302 raf = 0;
303 const box = textEl.getBoundingClientRect();
304 const dpr = Math.min(window.devicePixelRatio || 1, 2);
305 const W = Math.max(1, Math.round(box.width * dpr));
306 const H = Math.max(1, Math.round(box.height * dpr));
307 const cs = getComputedStyle(textEl);
308 const fs = parseFloat(cs.fontSize);
309 const mask = document.createElement("canvas");
310 mask.width = W;
311 mask.height = H;
312 const ctx = mask.getContext("2d");
313 if (!ctx) return;
314 ctx.fillStyle = "#000";
315 ctx.fillRect(0, 0, W, H);
316 ctx.scale(dpr, dpr);
317 ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
318 ctx.fillStyle = "#fff";
319 if ("letterSpacing" in ctx) (ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = cs.letterSpacing === "normal" ? "0px" : cs.letterSpacing;
320 const node = textEl.firstChild;
321 if (node && node.nodeType === 3) {
322 const text = node.textContent || "";
323 const range = document.createRange();
324 let lineTop = -1;
325 let start = 0;
326 const lines: { s: number; e: number; rect: DOMRect }[] = [];
327 for (let i = 0; i < text.length; i++) {
328 range.setStart(node, i);
329 range.setEnd(node, i + 1);
330 const r = range.getClientRects()[0];
331 if (!r) continue;
332 if (lineTop < 0) lineTop = r.top;
333 if (Math.abs(r.top - lineTop) > fs * 0.3) {
334 range.setStart(node, start);
335 range.setEnd(node, i);
336 lines.push({ s: start, e: i, rect: range.getBoundingClientRect() });
337 start = i;
338 lineTop = r.top;
339 }
340 }
341 range.setStart(node, start);
342 range.setEnd(node, text.length);
343 lines.push({ s: start, e: text.length, rect: range.getBoundingClientRect() });
344 const m = ctx.measureText("Hg");
345 const asc = m.fontBoundingBoxAscent ?? fs * 0.8;
346 const desc = m.fontBoundingBoxDescent ?? fs * 0.2;
347 for (const ln of lines) {
348 const y = ln.rect.top - box.top + (ln.rect.height - (asc + desc)) / 2 + asc;
349 ctx.fillText(text.slice(ln.s, ln.e).replace(/\s+$/, ""), ln.rect.left - box.left, y);
350 }
351 }
352 st.current.mask = mask;
353 st.current.dirty = true;
354 setReady(true);
355 kick.current();
356 };
357 const schedule = () => {
358 if (!raf) raf = requestAnimationFrame(build);
359 };
360 const ro = new ResizeObserver(schedule);
361 ro.observe(textEl);
362 document.fonts?.ready.then(schedule);
363 schedule();
364 return () => {
365 cancelAnimationFrame(raf);
366 ro.disconnect();
367 };
368 }, [children, failed, kick]);
369
370 // scroll → density (1 when the text sits in the middle band of the viewport)
371 useEffect(() => {
372 const el = rootRef.current;
373 if (!el || !scroll) return;
374 let raf = 0;
375 const read = () => {
376 raf = 0;
377 const r = el.getBoundingClientRect();
378 const vh = window.innerHeight || 1;
379 const center = (r.top + r.height / 2) / vh; // 0 top … 1 bottom
380 const enter = Math.min(1, Math.max(0, (1.05 - center) / 0.45));
381 const exit = Math.min(1, Math.max(0, (center + 0.05) / 0.35));
382 st.current.target = Math.min(enter, exit);
383 kick.current();
384 };
385 const on = () => {
386 if (!raf) raf = requestAnimationFrame(read);
387 };
388 const io = new IntersectionObserver(([e]) => {
389 if (e.isIntersecting) {
390 window.addEventListener("scroll", on, { passive: true });
391 window.addEventListener("resize", on);
392 on();
393 } else {
394 window.removeEventListener("scroll", on);
395 window.removeEventListener("resize", on);
396 st.current.target = 0;
397 }
398 });
399 io.observe(el);
400 return () => {
401 cancelAnimationFrame(raf);
402 io.disconnect();
403 window.removeEventListener("scroll", on);
404 window.removeEventListener("resize", on);
405 };
406 }, [scroll, kick]);
407
408 return (
409 <div ref={rootRef} className={`fd-dtext${className ? " " + className : ""}`} data-state={failed ? "fallback" : ready ? "ready" : "loading"} style={{ ["--fd-dtext-ink" as string]: ink, ...style }}>
410 <style>{css}</style>
411 <Tag ref={textRef as never} className="fd-dtext-text">
412 {children}
413 </Tag>
414 {!failed && <canvas ref={canvasRef} className="fd-dtext-canvas" aria-hidden="true" />}
415 </div>
416 );
417}
418
419const css = `
420.fd-dtext, .fd-dtext *, .fd-dtext *::before, .fd-dtext *::after { box-sizing: border-box; }
421.fd-dtext { position: relative; display: block; margin: 0; padding: 0; border: 0; background: transparent; }
422:where(.fd-dtext) { font-size: clamp(3rem, 10vw, 8.5rem); }
423.fd-dtext-text {
424 display: block; margin: 0; padding: 0.02em 0 0.08em; border: 0; background: transparent;
425 font-size: inherit; line-height: 0.92; text-transform: none; text-decoration: none;
426 color: transparent; -webkit-text-fill-color: transparent;
427}
428:where(.fd-dtext-text) { font-weight: 800; letter-spacing: -0.045em; font-family: "SF Pro Display", ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif; }
429.fd-dtext-text::selection { background: rgba(10,132,255,.3); }
430.fd-dtext[data-state="fallback"] .fd-dtext-text {
431 -webkit-text-fill-color: var(--fd-dtext-ink); color: var(--fd-dtext-ink);
432 -webkit-mask: radial-gradient(circle, #000 1.1px, transparent 1.5px) 0 0 / 3px 3px;
433 mask: radial-gradient(circle, #000 1.1px, transparent 1.5px) 0 0 / 3px 3px;
434}
435.fd-dtext-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; pointer-events: none; }
436`;
437
438/* ───────────────────────── demo ───────────────────────── */
439
440export function Demo() {
441 return (
442 <article className="fd-dtext-demo">
443 <style>{demoCss}</style>
444 <header className="fd-dtext-demo-mast">
445 <span>The Ledger Review</span>
446 <span>Issue 31 · Architecture</span>
447 </header>
448 <section className="fd-dtext-demo-block">
449 <p className="fd-dtext-demo-kicker">Scroll ↓</p>
450 <DitherText className="fd-dtext-demo-h" as="h1">Concrete remembers everything.</DitherText>
451 <p className="fd-dtext-demo-dek">A walk through Lina Bo Bardi's SESC Pompéia, forty years after it opened — and why São Paulo still argues about it.</p>
452 </section>
453 <section className="fd-dtext-demo-block">
454 <DitherText className="fd-dtext-demo-h" ink="#C2410C" angle={60}>The pool on the fourth floor.</DitherText>
455 <p className="fd-dtext-demo-body">The sports block rises in four stacked slabs, joined to its twin by flying concrete bridges. Each walkway is shaped differently, as if poured by a different hand. Children run across them between swimming and judo; nobody looks down.</p>
456 </section>
457 <section className="fd-dtext-demo-block">
458 <DitherText className="fd-dtext-demo-h" ink="#1E3A8A" angle={160} tone={[0.25, 0.95]}>Holes, not windows.</DitherText>
459 <p className="fd-dtext-demo-body">Bo Bardi called the openings "caves of prehistoric men". They have red wooden lattices instead of glass, and the wind moves through the whole building. By Maren Okonkwo · Photographs by Tomás Ferreira</p>
460 </section>
461 </article>
462 );
463}
464
465const demoCss = `
466.fd-dtext-demo, .fd-dtext-demo *, .fd-dtext-demo *::before, .fd-dtext-demo *::after { box-sizing: border-box; }
467.fd-dtext-demo {
468 margin: 0; padding: 0 clamp(20px, 6vw, 96px) 30vh; background: #F4F1EA; color: #1C1B19;
469 font: 400 17px/1.6 ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
470}
471.fd-dtext-demo-mast { position: sticky; top: 0; z-index: 2; display: flex; justify-content: space-between; margin: 0 0 0; padding: 20px 0; border-bottom: 1px solid #D8D2C4; background: #F4F1EA; color: #1C1B19; font: 600 12px/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: .12em; text-transform: uppercase; }
472.fd-dtext-demo-mast span { margin: 0; padding: 0; }
473.fd-dtext-demo-block { max-width: 1100px; min-height: 80vh; margin: 0 auto; padding: 18vh 0 0; }
474.fd-dtext-demo-kicker { margin: 0 0 16px; padding: 0; color: #8A8478; font: 500 13px/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: .08em; }
475.fd-dtext-demo-h.fd-dtext { font-size: clamp(3.25rem, 9.5vw, 8.5rem); margin: 0 0 28px; }
476.fd-dtext-demo-dek { max-width: 48ch; margin: 0; padding: 0; color: #4A463E; font: 400 1.25rem/1.5 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em; }
477.fd-dtext-demo-body { max-width: 60ch; margin: 0; padding: 0; color: #4A463E; font: 400 1.0625rem/1.65 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.005em; }
478`;

More Dither. Same file contract.

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 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.