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
  • Climate / Energy
  • React only · 0 deps
  • 1 file
Full screen

Browser support · WebGL2 / WebGL1. Without WebGL a dotted CSS gradient is shown.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/dither-gradient.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/dither/dither-gradient.tsx
1/**
2 * DitherGradient — a slow, flowing mesh-gradient backdrop (domain-warped noise
3 * fields) quantised through ordered dithering into 2–4 inks. Built for hero
4 * sections: put any content inside as children.
5 *
6 * Props
7 * - palette?: string[] 2–4 hex inks, darkest → lightest. Default deep pine → pale lichen.
8 * - pixelSize?: number Dither cell in CSS px. Default 3.
9 * - algorithm?: "bayer4" | "bayer8" | "ign" (ign = interleaved gradient noise, a cheap blue-ish noise). Default "bayer8".
10 * - speed?: number Flow speed multiplier. Default 1.
11 * - scale?: number Feature size; larger = bigger blobs. Default 1.
12 * - glow?: [x, y] Position (0–1) of a sun-like light pool; null to disable. Default [0.78, 0.28].
13 * - interactive?: boolean The light pool drifts toward the cursor. Default true.
14 * - brightness?: number −0.5…0.5 overall exposure. Default 0.
15 * - shade?: number 0–1 darkens the left side for headline legibility. Default 0.
16 * - children Content rendered above the backdrop.
17 * CSS vars: --fd-dgrad-fallback.
18 *
19 * Browser support: WebGL2 / WebGL1. Without WebGL a dotted CSS gradient is
20 * shown. Reduced motion: one still frame, no cursor follow. The canvas is
21 * aria-hidden; children carry the content.
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/* ───────────────────────── shader ───────────────────────── */
194
195const FRAG = /* glsl */ `
196uniform vec2 uRes;
197uniform float uTime;
198uniform float uPx;
199uniform float uAlgo;
200uniform vec3 uPal[4];
201uniform float uN;
202uniform float uScale;
203uniform vec3 uGlow;
204uniform float uBright;
205uniform float uShade;
206
207float bayer2(vec2 a){ a = floor(a); return fract(a.x * .5 + a.y * a.y * .75); }
208float bayer4(vec2 a){ return bayer2(.5 * a) * .25 + bayer2(a); }
209float bayer8(vec2 a){ return bayer4(.5 * a) * .25 + bayer2(a); }
210
211vec2 hash2(vec2 p){
212 p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
213 return -1. + 2. * fract(sin(p) * 43758.5453);
214}
215float noise(vec2 p){
216 vec2 i = floor(p), f = fract(p);
217 vec2 u = f * f * (3. - 2. * f);
218 return mix(mix(dot(hash2(i), f), dot(hash2(i + vec2(1, 0)), f - vec2(1, 0)), u.x),
219 mix(dot(hash2(i + vec2(0, 1)), f - vec2(0, 1)), dot(hash2(i + vec2(1, 1)), f - vec2(1, 1)), u.x), u.y);
220}
221float fbm(vec2 p){
222 float v = 0., a = .5;
223 mat2 r = mat2(.8, .6, -.6, .8);
224 for (int i = 0; i < 3; i++) { v += a * noise(p); p = r * p * 2.03 + 11.7; a *= .5; }
225 return v;
226}
227
228vec3 pal(float i){
229 vec3 c = uPal[0];
230 c = mix(c, uPal[1], step(.5, i));
231 c = mix(c, uPal[2], step(1.5, i));
232 c = mix(c, uPal[3], step(2.5, i));
233 return c;
234}
235
236void main(){
237 vec2 id = floor(gl_FragCoord.xy / uPx);
238 vec2 c = (id + .5) * uPx;
239 vec2 uv = c / uRes;
240 float asp = uRes.x / uRes.y;
241 vec2 p = vec2(uv.x * asp, uv.y) * (1.6 / uScale);
242 float t = uTime * .06;
243
244 // two-level domain warp → soft, liquid "mesh gradient" bands
245 vec2 q = vec2(fbm(p + vec2(0., t)), fbm(p + vec2(5.2, 1.3) - t * .7));
246 vec2 r = vec2(fbm(p + 1.8 * q + vec2(1.7, 9.2) + t * .5), fbm(p + 1.8 * q + vec2(8.3, 2.8) - t * .3));
247 float f = fbm(p + 2.2 * r);
248 float l = .5 + f * 1.15 + .25 * length(q) - .1;
249
250 // vertical atmosphere: darker toward the bottom for legibility
251 l += (uv.y - .5) * .35 + uBright;
252 // optional shade on the copy side (left), keeps headlines legible
253 // on portrait screens copy spans the full width, so the shade widens to cover it
254 float reach = mix(.62, 1.7, step(asp, 1.));
255 l -= uShade * (1. - smoothstep(.0, reach, uv.x)) * .55;
256
257 // light pool
258 if (uGlow.z > 0.) {
259 vec2 g = vec2((uv.x - uGlow.x) * asp, uv.y - uGlow.y);
260 float d = length(g);
261 l += uGlow.z * (.38 * exp(-d * d * 14.) + .14 * exp(-d * 2.6));
262 }
263 // soft shoulder so highlights roll off instead of clipping to a flat ink
264 l = clamp(l, 0., 1.25);
265 l = l / (1. + max(l - .85, 0.) * 1.6);
266 l = clamp(l, 0., 1.);
267
268 float th;
269 if (uAlgo < .5) th = bayer4(id) + .03125;
270 else if (uAlgo < 1.5) th = bayer8(id) + .0078125;
271 else th = fract(52.9829189 * fract(dot(id, vec2(.06711056, .00583715))));
272
273 float n = uN - 1.;
274 float v = l * n;
275 float idx = min(floor(v) + step(th, fract(v)), n);
276 gl_FragColor = vec4(pal(idx), 1.);
277}
278`;
279
280/* ───────────────────────── component ───────────────────────── */
281
282export interface DitherGradientProps {
283 palette?: string[];
284 pixelSize?: number;
285 algorithm?: "bayer4" | "bayer8" | "ign";
286 speed?: number;
287 scale?: number;
288 glow?: [number, number] | null;
289 interactive?: boolean;
290 brightness?: number;
291 shade?: number;
292 className?: string;
293 style?: CSSProperties;
294 children?: ReactNode;
295}
296
297const ALGOS = { bayer4: 0, bayer8: 1, ign: 2 };
298
299export function DitherGradient({
300 palette = ["#0B1A14", "#1E4D3A", "#7FA77A", "#E4EDC9"],
301 pixelSize = 3,
302 algorithm = "bayer8",
303 speed = 1,
304 scale = 1,
305 glow = [0.78, 0.28],
306 interactive = true,
307 brightness = 0,
308 shade = 0,
309 className,
310 style,
311 children,
312}: DitherGradientProps) {
313 const rootRef = useRef<HTMLDivElement>(null);
314 const canvasRef = useRef<HTMLCanvasElement>(null);
315 const props = useRef({ palette, pixelSize, algorithm, speed, scale, glow, interactive, brightness, shade });
316 props.current = { palette, pixelSize, algorithm, speed, scale, glow, interactive, brightness, shade };
317 const g = useRef({ x: glow?.[0] ?? 0.5, y: glow?.[1] ?? 0.5, tx: -1, ty: -1, t: 0 });
318
319 const hooks = useRef<StageHooks>({
320 frame(s, _time, dt) {
321 const { gl, u } = s;
322 const p = props.current;
323 const gs = g.current;
324 if (!s.reduced) gs.t += dt * p.speed;
325 const homeX = p.glow?.[0] ?? 0.5;
326 const homeY = p.glow?.[1] ?? 0.5;
327 const tx = gs.tx >= 0 && p.interactive ? homeX + (gs.tx - homeX) * 0.35 : homeX;
328 const ty = gs.ty >= 0 && p.interactive ? homeY + (gs.ty - homeY) * 0.35 : homeY;
329 const k = s.reduced ? 1 : 1 - Math.exp(-dt * 1.6);
330 gs.x += (tx - gs.x) * k;
331 gs.y += (ty - gs.y) * k;
332 const pal = p.palette.slice(0, 4);
333 while (pal.length < 2) pal.push("#ffffff");
334 const flat: number[] = [];
335 for (let i = 0; i < 4; i++) flat.push(...hexToRgb(pal[Math.min(i, pal.length - 1)]));
336 gl.uniform2f(u.uRes, s.w, s.h);
337 gl.uniform1f(u.uTime, gs.t + 20);
338 gl.uniform1f(u.uPx, Math.max(1, Math.round(p.pixelSize * s.dpr)));
339 gl.uniform1f(u.uAlgo, ALGOS[p.algorithm] ?? 1);
340 gl.uniform3fv(u.uPal, new Float32Array(flat));
341 gl.uniform1f(u.uN, pal.length);
342 gl.uniform1f(u.uScale, p.scale);
343 gl.uniform3f(u.uGlow, gs.x, 1 - gs.y, p.glow ? 1 : 0);
344 gl.uniform1f(u.uBright, p.brightness);
345 gl.uniform1f(u.uShade, p.shade);
346 return true;
347 },
348 });
349 // Dither cells are chunky, so render at DPR 1 when pixelSize ≥ 2 — same look, ¼ the fragments.
350 const { failed, kick } = useStage(canvasRef, FRAG, hooks, pixelSize >= 2 ? 1 : 2);
351
352 useEffect(() => kick.current(), [palette.join(), pixelSize, algorithm, scale, glow?.[0], glow?.[1], brightness, shade, kick]);
353
354 useEffect(() => {
355 const el = rootRef.current;
356 if (!el || !interactive) return;
357 const move = (e: PointerEvent) => {
358 const r = el.getBoundingClientRect();
359 g.current.tx = (e.clientX - r.left) / r.width;
360 g.current.ty = (e.clientY - r.top) / r.height;
361 };
362 const leave = () => {
363 g.current.tx = -1;
364 g.current.ty = -1;
365 };
366 el.addEventListener("pointermove", move);
367 el.addEventListener("pointerleave", leave);
368 return () => {
369 el.removeEventListener("pointermove", move);
370 el.removeEventListener("pointerleave", leave);
371 };
372 }, [interactive]);
373
374 const fallback = `radial-gradient(60% 70% at ${(glow?.[0] ?? 0.5) * 100}% ${(glow?.[1] ?? 0.5) * 100}%, ${palette[palette.length - 1]} 0%, ${palette[Math.min(2, palette.length - 1)]} 30%, ${palette[1] ?? palette[0]} 60%, ${palette[0]} 100%)`;
375
376 return (
377 <div ref={rootRef} className={`fd-dgrad${className ? " " + className : ""}`} style={{ ["--fd-dgrad-fallback" as string]: fallback, ["--fd-dgrad-base" as string]: palette[0], ...style }}>
378 <style>{css}</style>
379 {failed ? <div className="fd-dgrad-fallback" aria-hidden="true" /> : <canvas ref={canvasRef} className="fd-dgrad-canvas" aria-hidden="true" />}
380 <div className="fd-dgrad-content">{children}</div>
381 </div>
382 );
383}
384
385const css = `
386.fd-dgrad, .fd-dgrad *, .fd-dgrad *::before, .fd-dgrad *::after { box-sizing: border-box; }
387.fd-dgrad { position: relative; display: block; margin: 0; padding: 0; border: 0; overflow: hidden; background: var(--fd-dgrad-base); color: inherit; isolation: isolate; }
388.fd-dgrad-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; image-rendering: pixelated; z-index: 0; }
389.fd-dgrad-fallback { position: absolute; inset: 0; margin: 0; padding: 0; border: 0; z-index: 0; background: var(--fd-dgrad-fallback); }
390.fd-dgrad-fallback::after {
391 content: ""; position: absolute; inset: 0; background: var(--fd-dgrad-base); opacity: .55;
392 -webkit-mask: radial-gradient(circle, transparent 1px, #000 1.6px) 0 0 / 3px 3px;
393 mask: radial-gradient(circle, transparent 1px, #000 1.6px) 0 0 / 3px 3px;
394}
395.fd-dgrad-content { position: relative; z-index: 1; margin: 0; padding: 0; }
396`;
397
398/* ───────────────────────── demo ───────────────────────── */
399
400export function Demo() {
401 return (
402 <DitherGradient className="fd-dgrad-demo" shade={0.7} brightness={-0.04}>
403 <style>{demoCss}</style>
404 <header className="fd-dgrad-demo-nav">
405 <span className="fd-dgrad-demo-logo">
406 <svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
407 <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" />
408 <path d="M5 15c3-1 4-6 7-6s4 5 7 6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
409 </svg>
410 Canopy Grid
411 </span>
412 <span className="fd-dgrad-demo-status">
413 <span className="fd-dgrad-demo-pulse" aria-hidden="true" /> Live · 2.41 GW flowing
414 </span>
415 </header>
416 <div className="fd-dgrad-demo-hero">
417 <p className="fd-dgrad-demo-kicker">Community solar for the Nordics</p>
418 <h1 className="fd-dgrad-demo-title">Power that grows<br />where you live.</h1>
419 <p className="fd-dgrad-demo-body">
420 Subscribe to a share of a solar farm near you. Canopy feeds clean kilowatt-hours straight into your local grid — no roof, no installer, 12% off your bill from the first month.
421 </p>
422 <div className="fd-dgrad-demo-actions">
423 <a className="fd-dgrad-demo-primary" href="#join" onClick={(e) => e.preventDefault()}>Check your postcode</a>
424 <a className="fd-dgrad-demo-secondary" href="#farms" onClick={(e) => e.preventDefault()}>See our 14 farms →</a>
425 </div>
426 </div>
427 <dl className="fd-dgrad-demo-stats">
428 <div><dt>Households powered</dt><dd>186,420</dd></div>
429 <div><dt>CO₂ avoided, 2026</dt><dd>212 kt</dd></div>
430 <div><dt>Avg. saving / yr</dt><dd>€318</dd></div>
431 </dl>
432 </DitherGradient>
433 );
434}
435
436const demoCss = `
437.fd-dgrad-demo { min-height: 100vh; }
438.fd-dgrad-demo .fd-dgrad-content {
439 display: flex; flex-direction: column; min-height: 100vh; margin: 0; padding: 0 clamp(20px, 5vw, 72px);
440 color: #F2F6E6; font: 400 15px/1.5 ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
441}
442.fd-dgrad-demo-nav { display: flex; align-items: center; justify-content: space-between; height: 76px; margin: 0; padding: 0; }
443.fd-dgrad-demo-logo { display: inline-flex; align-items: center; gap: 10px; margin: 0; padding: 0; color: #F2F6E6; font: 650 17px/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em; }
444.fd-dgrad-demo-logo svg { display: block; }
445.fd-dgrad-demo-status { display: inline-flex; align-items: center; gap: 8px; margin: 0; padding: 7px 12px; border-radius: 9999px; background: rgba(11, 26, 20, .72); color: #E4EDC9; font: 500 12px/1 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: 0.02em; }
446.fd-dgrad-demo-pulse { display: block; width: 7px; height: 7px; margin: 0; padding: 0; border-radius: 50%; background: #B9E06A; box-shadow: 0 0 0 0 rgba(185,224,106,.6); animation: fd-dgrad-pulse 2.4s cubic-bezier(0.22, 1, 0.36, 1) infinite; }
447@keyframes fd-dgrad-pulse { 0% { box-shadow: 0 0 0 0 rgba(185,224,106,.55); } 100% { box-shadow: 0 0 0 10px rgba(185,224,106,0); } }
448.fd-dgrad-demo-hero { flex: 1; display: flex; flex-direction: column; justify-content: center; max-width: 760px; margin: 0; padding: clamp(32px, 8vh, 96px) 0; }
449.fd-dgrad-demo-kicker { margin: 0 0 20px; padding: 0; color: #CFE0A8; font: 600 13px/1.3 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: 0.1em; text-transform: uppercase; text-shadow: 0 1px 12px rgba(11,26,20,.7); }
450.fd-dgrad-demo-title { margin: 0 0 24px; padding: 0; color: #F7FAEE; letter-spacing: -0.045em; font: 700 clamp(3rem, 7.4vw, 6.75rem)/0.94 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", "Segoe UI", Roboto, sans-serif; text-shadow: 0 2px 40px rgba(11,26,20,.55); }
451.fd-dgrad-demo-body { max-width: 50ch; margin: 0 0 36px; padding: 0; color: #E2EBD2; font: 400 1.0625rem/1.55 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em; text-shadow: 0 1px 16px rgba(11,26,20,.9); }
452.fd-dgrad-demo-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 24px; margin: 0; padding: 0; }
453.fd-dgrad-demo-primary { display: inline-flex; align-items: center; height: 52px; margin: 0; padding: 0 26px; border: 0; border-radius: 9999px; background: #E4EDC9; color: #0B1A14; text-decoration: none; font: 650 0.9375rem/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.01em; transition: transform 120ms cubic-bezier(0.22, 1, 0.36, 1), background 120ms cubic-bezier(0.22, 1, 0.36, 1); }
454.fd-dgrad-demo-primary:hover { background: #FFFFFF; }
455.fd-dgrad-demo-primary:active { transform: scale(0.96); }
456.fd-dgrad-demo-secondary { margin: 0; padding: 0; color: #F2F6E6; text-decoration: none; font: 550 0.9375rem/1 ui-sans-serif, -apple-system, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif; text-shadow: 0 1px 12px rgba(11,26,20,.8); }
457.fd-dgrad-demo-secondary:hover { text-decoration: underline; text-underline-offset: 4px; }
458.fd-dgrad-demo-primary:focus-visible, .fd-dgrad-demo-secondary:focus-visible { outline: 2px solid rgba(228,237,201,.7); outline-offset: 2px; }
459.fd-dgrad-demo-stats { display: grid; grid-template-columns: repeat(3, minmax(0, max-content)); gap: clamp(20px, 6vw, 88px); margin: 0 0 40px; padding: 20px 24px; border-radius: 20px; background: rgba(11, 26, 20, .82); width: max-content; max-width: 100%; }
460.fd-dgrad-demo-stats div { margin: 0; padding: 0; }
461.fd-dgrad-demo-stats dt { margin: 0 0 6px; padding: 0; color: #8FAF8A; font: 500 12px/1.2 ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; letter-spacing: 0.04em; text-transform: uppercase; }
462.fd-dgrad-demo-stats dd { margin: 0; padding: 0; color: #F2F6E6; font: 650 24px/1.1 "SF Pro Display", ui-sans-serif, -apple-system, "Inter", "Segoe UI", Roboto, sans-serif; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
463@media (max-width: 640px) {
464 .fd-dgrad-demo-stats { grid-template-columns: 1fr 1fr; width: auto; }
465 .fd-dgrad-demo-status { display: none; }
466}
467@media (prefers-reduced-motion: reduce) { .fd-dgrad-demo-pulse { animation: none; } }
468`;

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 Text

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