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.
Headlines printed as a Bayer halftone — dots ink in as the text scrolls into view and dissolve as it leaves.
npx shadcn@latest add https://freedesignmd.com/r/dither-text.json1/**2 * DitherText — a headline printed as an ordered-dither halftone. The text is3 * rasterised to a mask, then a shader fills it with Bayer-dithered tone whose4 * density follows scroll: dots build up as the headline scrolls into view5 * and dissolve as it leaves. A soft light gradient across the letters gives6 * the dots a printed, tonal look.7 *8 * Props9 * - 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 a19 * 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 scroll199uniform 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 scroll212 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 cell219 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 layout297 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 bottom380 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`;Bayer and blue-noise dithering of any image into a 2–4 ink palette, with a cursor loupe that sharpens, inverts or reveals.
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.