Luminance-to-glyph canvas renderer: ASCII, Bayer-dither, and dot-matrix modes with cursor-proximity resolve.
npx shadcn add https://design.helpmarq.com /r/background-ascii-dither.jsonregistry/core/background-ascii-dither/component.tsx"use client";
import { useEffect, useRef } from "react";
const ASCII_RAMP = " .:-=+*#%@";
// 4x4 Bayer matrix, normalized 0..1
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(
(v) => (v + 0.5) / 16
);
type Mode = "ascii" | "dither" | "dot";
// cheap flowing value-noise, no deps
function noise(x: number, y: number, t: number): number {
const v =
Math.sin(x * 1.7 + t * 0.6) +
Math.sin(y * 2.3 - t * 0.4) +
Math.sin((x + y) * 1.1 + t * 0.25) +
Math.sin(Math.hypot(x - 6, y - 4) * 1.9 - t * 0.7);
return v / 8 + 0.5; // 0..1
}
export function AsciiDitherMedia({
mode = "ascii",
src,
cellSize = 14,
cursorRadius = 140,
className = "",
}: {
mode?: Mode;
/** optional image URL; omit for the built-in animated noise field */
src?: string;
/** grid cell size in px */
cellSize?: number;
/** cursor-proximity resolve radius in px */
cursorRadius?: number;
className?: string;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// glyph color from the surrounding theme; canvas bg stays transparent
const fg = getComputedStyle(canvas).color;
let cols = 0;
let rows = 0;
let dpr = 1;
let imageLum: Float32Array | null = null;
const cursor = { x: -1e4, y: -1e4, tx: -1e4, ty: -1e4 };
let raf = 0;
let t = 0;
// bumped on every sampleImage() dispatch so a late-resolving onload from
// a superseded resize (rapid window-resize) can detect it's stale and
// bail instead of writing imageLum sized to an out-of-date cols/rows
let sampleGen = 0;
const resize = () => {
dpr = Math.min(window.devicePixelRatio || 1, 2);
const { width, height } = canvas.getBoundingClientRect();
canvas.width = width * dpr;
canvas.height = height * dpr;
cols = Math.ceil(width / cellSize);
rows = Math.ceil(height / cellSize);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.font = `${cellSize * 0.85}px "GeistMono", ui-monospace, monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (src) sampleImage();
};
// coalesce bursts of resize events (dragging a window edge fires dozens)
// into one recompute + one image refetch/redecode
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
const onResize = () => {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
resizeTimer = null;
resize();
}, 150);
};
const sampleImage = () => {
if (!src) return;
const gen = ++sampleGen;
const sampleCols = cols;
const sampleRows = rows;
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
if (gen !== sampleGen) return; // superseded by a newer resize/sample
const buf = document.createElement("canvas");
buf.width = sampleCols;
buf.height = sampleRows;
const bctx = buf.getContext("2d");
if (!bctx) return;
// cover-fit the image into the grid
const scale = Math.max(sampleCols / img.width, sampleRows / img.height);
const w = img.width * scale;
const h = img.height * scale;
bctx.drawImage(img, (sampleCols - w) / 2, (sampleRows - h) / 2, w, h);
const data = bctx.getImageData(0, 0, sampleCols, sampleRows).data;
const lum = new Float32Array(sampleCols * sampleRows);
for (let i = 0; i < sampleCols * sampleRows; i++) {
lum[i] =
(0.2126 * data[i * 4] + 0.7152 * data[i * 4 + 1] + 0.0722 * data[i * 4 + 2]) / 255;
}
imageLum = lum;
if (reduced) draw(); // static sources still need one paint
};
img.src = src;
};
const draw = () => {
const { width, height } = canvas.getBoundingClientRect();
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = fg;
// ease cursor for a trailing resolve
cursor.x += (cursor.tx - cursor.x) * 0.12;
cursor.y += (cursor.ty - cursor.y) * 0.12;
const r2 = cursorRadius * cursorRadius;
for (let gy = 0; gy < rows; gy++) {
for (let gx = 0; gx < cols; gx++) {
// gamma deepens the noise floor: large areas go empty, crests pop.
// x-advection makes the field travel, not just undulate in place
let lum = imageLum
? imageLum[gy * cols + gx]
: Math.pow(noise(gx * 0.35 + t * 0.22, gy * 0.35, t), 1.7);
const px = gx * cellSize + cellSize / 2;
const py = gy * cellSize + cellSize / 2;
// gaussian-ish cursor boost
const dx = px - cursor.x;
const dy = py - cursor.y;
const d2 = dx * dx + dy * dy;
if (d2 < r2 * 4) lum = Math.min(1, lum + Math.exp(-d2 / r2) * 0.7);
if (mode === "ascii") {
const ch = ASCII_RAMP[Math.floor(lum * (ASCII_RAMP.length - 1))];
if (ch !== " ") {
ctx.globalAlpha = 0.25 + lum * 0.75;
ctx.fillText(ch, px, py);
}
} else if (mode === "dither") {
if (lum > BAYER[(gy % 4) * 4 + (gx % 4)]) {
ctx.globalAlpha = 0.9;
ctx.fillRect(px - 1, py - 1, 2, 2);
}
} else {
const rad = (lum * cellSize) / 2.4;
if (rad > 0.4) {
ctx.globalAlpha = 0.3 + lum * 0.7;
ctx.beginPath();
ctx.arc(px, py, rad, 0, Math.PI * 2);
ctx.fill();
}
}
}
}
ctx.globalAlpha = 1;
};
const loop = () => {
t += 0.028;
draw();
raf = requestAnimationFrame(loop);
};
const onPointer = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
cursor.tx = e.clientX - rect.left;
cursor.ty = e.clientY - rect.top;
};
const onLeave = () => {
cursor.tx = -1e4;
cursor.ty = -1e4;
};
resize();
window.addEventListener("resize", onResize);
canvas.addEventListener("pointermove", onPointer);
canvas.addEventListener("pointerleave", onLeave);
if (reduced) {
draw(); // single static frame
} else {
raf = requestAnimationFrame(loop);
}
return () => {
cancelAnimationFrame(raf);
if (resizeTimer) clearTimeout(resizeTimer);
window.removeEventListener("resize", onResize);
canvas.removeEventListener("pointermove", onPointer);
canvas.removeEventListener("pointerleave", onLeave);
};
}, [mode, src, cellSize, cursorRadius]);
return (
<canvas
ref={canvasRef}
aria-hidden
className={`block h-full w-full text-foreground ${className}`}
/>
);
}
A full-bleed canvas engine that maps a source (animated flowing noise by default, or an image) to a luminance-driven monochrome glyph grid with three modes: ASCII characters in a density ramp, 4x4 Bayer-matrix dithered pixels, and dot-matrix circles sized by brightness. The cursor brightens and resolves nearby cells with a Gaussian falloff and eased trailing. Direct-DOM rAF loop with no React state, theme-aware glyph color, single static frame under prefers-reduced-motion. Window resize is debounced (150ms) so dragging a window edge doesn't spam the grid recompute or (in image mode) refetch/redecode the source on every event; a generation counter discards any image decode superseded by a newer resize before it can write stale-sized data.