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.
A slow, flowing mesh gradient quantised through Bayer dithering into 2–4 inks — a hero backdrop with a light pool that follows the cursor.
npx shadcn@latest add https://freedesignmd.com/r/dither-gradient.json1/**2 * DitherGradient — a slow, flowing mesh-gradient backdrop (domain-warped noise3 * fields) quantised through ordered dithering into 2–4 inks. Built for hero4 * sections: put any content inside as children.5 *6 * Props7 * - 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 is20 * shown. Reduced motion: one still frame, no cursor follow. The canvas is21 * 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" bands245 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 legibility251 l += (uv.y - .5) * .35 + uBright;252 // optional shade on the copy side (left), keeps headlines legible253 // on portrait screens copy spans the full width, so the shade widens to cover it254 float reach = mix(.62, 1.7, step(asp, 1.));255 l -= uShade * (1. - smoothstep(.0, reach, uv.x)) * .55;256 257 // light pool258 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 ink264 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 Grid411 </span>412 <span className="fd-dgrad-demo-status">413 <span className="fd-dgrad-demo-pulse" aria-hidden="true" /> Live · 2.41 GW flowing414 </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`;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.
Headlines printed as a Bayer halftone — dots ink in as the text scrolls into view and dissolve as it leaves.