"use client";
import * as React from "react";
export type AsciiFieldMode = "noise" | "waves" | "rings";
export interface AsciiFieldProps extends React.ComponentPropsWithoutRef<"div"> {
/** Density ramp from empty to full. The first character is drawn for the lowest values. */
charset?: string;
/** Row height in px; the column width follows the monospace glyph width. */
cellSize?: number;
/** Animation speed multiplier. 0 freezes the field (pointer feedback still works). */
speed?: number;
/** Field function sampled on the character grid. */
mode?: AsciiFieldMode;
/** Tint the densest characters toward the accent colour. */
colorize?: boolean;
/** Accent colour for dense characters, hover glow and ripples (any CSS colour). */
accent?: string;
/** Brighten the field under a mouse pointer and send a ripple out from clicks and taps. */
interactive?: boolean;
}
type RGB = [number, number, number];
/* ---------------------------------- Colour --------------------------------- */
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */
function resolveColor(input: string, fallback: RGB): RGB {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input; // invalid colours are ignored → transparent → fallback
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : [r, g, b];
}
const luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
const mix = (a: RGB, b: RGB, t: number): RGB => [0, 1, 2].map((i) => Math.round(a[i] + (b[i] - a[i]) * t)) as RGB;
/** On light themes (dark ink), very light accents are pulled toward the ink so they stay visible. */
function legible(color: RGB, ink: RGB): RGB {
if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;
return mix(color, ink, 0.45);
}
/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */
function observeTheme(el: Element, onChange: () => void) {
const mo = new MutationObserver(onChange);
const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
mo.observe(document.documentElement, options);
const scoped = el.parentElement?.closest("[data-theme]");
if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => {
mo.disconnect();
mq.removeEventListener("change", onChange);
};
}
/* ---------------------------------- Noise ---------------------------------- */
/** Compact seeded 3D simplex noise (after Stefan Gustavson). Returns values in roughly [-1, 1]. */
function createNoise3D(seed = 7) {
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
let s = seed >>> 0 || 1;
for (let i = 255; i > 0; i--) {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
const j = s % (i + 1);
const t = p[i];
p[i] = p[j];
p[j] = t;
}
const perm = new Uint8Array(512);
for (let i = 0; i < 512; i++) perm[i] = p[i & 255];
const grad = [1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1];
const F3 = 1 / 3;
const G3 = 1 / 6;
const corner = (hash: number, x: number, y: number, z: number) => {
let t = 0.6 - x * x - y * y - z * z;
if (t < 0) return 0;
const g = (hash % 12) * 3;
t *= t;
return t * t * (grad[g] * x + grad[g + 1] * y + grad[g + 2] * z);
};
return (x: number, y: number, z: number) => {
const sk = (x + y + z) * F3;
const i = Math.floor(x + sk);
const j = Math.floor(y + sk);
const k = Math.floor(z + sk);
const t = (i + j + k) * G3;
const x0 = x - (i - t);
const y0 = y - (j - t);
const z0 = z - (k - t);
let i1, j1, k1, i2, j2, k2;
if (x0 >= y0) {
if (y0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 1, 0];
else if (x0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 0, 1];
else [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 1, 0, 1];
} else if (y0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 0, 1, 1];
else if (x0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 0, 1, 1];
else [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 1, 1, 0];
const ii = i & 255;
const jj = j & 255;
const kk = k & 255;
const n0 = corner(perm[ii + perm[jj + perm[kk]]], x0, y0, z0);
const n1 = corner(perm[ii + i1 + perm[jj + j1 + perm[kk + k1]]], x0 - i1 + G3, y0 - j1 + G3, z0 - k1 + G3);
const n2 = corner(perm[ii + i2 + perm[jj + j2 + perm[kk + k2]]], x0 - i2 + 2 * G3, y0 - j2 + 2 * G3, z0 - k2 + 2 * G3);
const n3 = corner(perm[ii + 1 + perm[jj + 1 + perm[kk + 1]]], x0 - 0.5, y0 - 0.5, z0 - 0.5);
return 32 * (n0 + n1 + n2 + n3);
};
}
/* ----------------------------- Reduced motion ------------------------------ */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------- Component -------------------------------- */
const DEFAULT_CHARSET = " .:-=+*#%@";
const FONT = 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace';
const FRAME_MS = 1000 / 30; // ASCII reads best (and costs least) at ~30fps
const HOVER_RADIUS = 120;
const RIPPLE_SPEED = 280; // px per second
const RIPPLE_LIFE = 2.2; // seconds
const MAX_RIPPLES = 6;
const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);
const smoothstep = (a: number, b: number, x: number) => {
const t = clamp01((x - a) / (b - a));
return t * t * (3 - 2 * t);
};
/**
* An animated ASCII-art field: a noise, wave or ring function is sampled once per character
* cell and mapped onto a density ramp. Glyphs are pre-rendered into a small atlas (one tinted
* copy per ramp step), so each frame is a batch of `drawImage` blits rather than `fillText` calls.
*/
export function AsciiField({
charset = DEFAULT_CHARSET,
cellSize = 14,
speed = 1,
mode = "noise",
colorize = true,
accent = "#FF7448",
interactive = true,
className = "",
children,
...props
}: AsciiFieldProps) {
const reduceMotion = usePrefersReducedMotion();
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const glyphs = Array.from(charset.length ? charset : DEFAULT_CHARSET);
const levels = glyphs.length;
const rowH = Math.max(6, cellSize);
const fontPx = Math.round(rowH * 0.86);
const noise = createNoise3D(23);
const atlas = document.createElement("canvas");
const actx = atlas.getContext("2d");
if (!actx) return;
let width = 0;
let height = 0;
let dpr = 1;
let colW = rowH * 0.6;
let cols = 0;
let rows = 0;
let time = 0;
let raf = 0;
let last = 0;
let sinceDraw = FRAME_MS;
let inView = false;
let accentRGB: RGB = [255, 116, 72];
let atlasDirty = true;
const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, hover: 0 };
let ripples: { x: number; y: number; age: number }[] = [];
/** Pre-renders every glyph of the ramp, each in its own tint and opacity. */
const buildAtlas = () => {
const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);
const dark = luminance(ink) > 0.5;
accentRGB = legible(resolveColor(accent, [255, 116, 72]), ink);
actx.font = `500 ${fontPx}px ${FONT}`;
colW = Math.max(3, actx.measureText("M").width);
atlas.width = Math.ceil(levels * colW * dpr);
atlas.height = Math.ceil(rowH * dpr);
actx.setTransform(dpr, 0, 0, dpr, 0, 0);
actx.clearRect(0, 0, levels * colW, rowH);
actx.font = `500 ${fontPx}px ${FONT}`;
actx.textAlign = "center";
actx.textBaseline = "middle";
for (let i = 0; i < levels; i++) {
const t = levels > 1 ? i / (levels - 1) : 1;
const tint = colorize ? mix(ink, accentRGB, smoothstep(0.45, 1, t)) : ink;
const alpha = dark ? 0.2 + 0.72 * t : 0.16 + 0.66 * t;
actx.fillStyle = `rgba(${tint[0]}, ${tint[1]}, ${tint[2]}, ${alpha.toFixed(3)})`;
actx.fillText(glyphs[i], i * colW + colW / 2, rowH / 2 + 0.5);
}
cols = Math.ceil(width / colW);
rows = Math.ceil(height / rowH);
atlasDirty = false;
};
const noiseFreq = 1 / 240;
/** Field value in [0, 1] at a cell centre. */
const sample = (x: number, y: number, t: number) => {
if (mode === "waves") {
return (
0.5 +
0.5 *
(Math.sin(x * 0.017 + t * 0.9) * 0.42 +
Math.sin(y * 0.034 - t * 0.6 + x * 0.006) * 0.34 +
Math.sin((x * 0.6 - y) * 0.013 + t * 0.45) * 0.24)
);
}
if (mode === "rings") {
const dx = x - width / 2;
const dy = y - height / 2;
const d = Math.sqrt(dx * dx + dy * dy);
const warp = noise(x * noiseFreq, y * noiseFreq, t * 0.12) * 1.6;
return 0.5 + 0.46 * Math.sin(d * 0.042 - t * 1.5 + warp) * (0.55 + 0.45 * Math.exp(-d / (Math.max(width, height) * 0.6)));
}
const n = 0.68 * noise(x * noiseFreq, y * noiseFreq * 1.4, t * 0.16) + 0.32 * noise(x * noiseFreq * 2.3, y * noiseFreq * 3, t * 0.28 + 9);
return 0.5 + 0.8 * n;
};
const draw = () => {
if (atlasDirty) buildAtlas();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
const cw = colW * dpr;
const ch = rowH * dpr;
const hover = interactive ? ptr.hover : 0;
const r2 = HOVER_RADIUS * HOVER_RADIUS;
const top = levels - 1;
for (let row = 0; row < rows; row++) {
const y = (row + 0.5) * rowH;
for (let col = 0; col < cols; col++) {
const x = (col + 0.5) * colW;
let v = sample(x, y, time);
if (hover > 0.01) {
const dx = x - ptr.x;
const dy = y - ptr.y;
v += 0.6 * hover * Math.exp(-(dx * dx + dy * dy) / r2);
}
for (const rp of ripples) {
const dx = x - rp.x;
const dy = y - rp.y;
const ring = Math.sqrt(dx * dx + dy * dy) - rp.age * RIPPLE_SPEED;
if (ring > -60 && ring < 60) v += 0.7 * (1 - rp.age / RIPPLE_LIFE) * Math.exp(-(ring * ring) / 700);
}
const level = Math.round(clamp01(v) * top);
if (glyphs[level] === " ") continue;
ctx.drawImage(atlas, level * cw, 0, cw, ch, col * cw, row * ch, cw, ch);
}
}
};
const frame = (now: number) => {
raf = 0;
const dt = Math.min(100, last ? now - last : FRAME_MS);
last = now;
time += (dt / 1000) * speed;
if (ptr.inside) {
const rect = canvas.getBoundingClientRect();
ptr.x = ptr.clientX - rect.left;
ptr.y = ptr.clientY - rect.top;
}
ptr.hover += ((ptr.inside ? 1 : 0) - ptr.hover) * (1 - Math.pow(0.88, dt / 16.7));
for (const rp of ripples) rp.age += dt / 1000;
ripples = ripples.filter((rp) => rp.age < RIPPLE_LIFE);
sinceDraw += dt;
if (sinceDraw >= FRAME_MS - 2) {
sinceDraw = 0;
draw();
}
// With speed 0 the loop idles once the pointer glow and ripples have settled.
const busy = speed > 0 || ripples.length > 0 || ptr.inside || ptr.hover > 0.01;
if (busy) schedule();
else draw();
};
const schedule = () => {
if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
last = 0;
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
const nextDpr = Math.min(2, window.devicePixelRatio || 1);
if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && nextDpr === dpr && cols > 0) return;
width = rect.width;
height = rect.height;
dpr = nextDpr;
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
atlasDirty = true;
draw();
schedule();
};
const onThemeChange = () => {
atlasDirty = true;
if (!raf) draw();
};
const onPointerMove = (e: PointerEvent) => {
if (e.pointerType !== "mouse" && e.pointerType !== "pen") return;
const rect = canvas.getBoundingClientRect();
ptr.clientX = e.clientX;
ptr.clientY = e.clientY;
const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
if (inside && !ptr.inside && ptr.hover < 0.01) {
ptr.x = e.clientX - rect.left;
ptr.y = e.clientY - rect.top;
}
ptr.inside = inside;
if (inside) schedule();
};
const onPointerDown = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x < 0 || y < 0 || x > rect.width || y > rect.height) return;
ripples.push({ x, y, age: 0 });
if (ripples.length > MAX_RIPPLES) ripples.shift();
schedule();
};
const onPointerLeave = () => {
ptr.inside = false;
};
const onVisibility = () => (document.hidden ? stop() : schedule());
// Pointer tracking lives on the window (only while visible), so content layered above the
// field never blocks it and the field never blocks the content.
const listenPointer = interactive && !reduceMotion;
const io = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
if (inView) {
if (listenPointer) {
window.addEventListener("pointermove", onPointerMove, { passive: true });
window.addEventListener("pointerdown", onPointerDown, { passive: true });
document.documentElement.addEventListener("pointerleave", onPointerLeave);
}
schedule();
} else {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerdown", onPointerDown);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
ptr.inside = false;
stop();
}
});
// Reduced motion: a single still frame from a moment where every mode has good contrast.
if (reduceMotion) time = 4;
resize();
const ro = new ResizeObserver(resize);
ro.observe(canvas);
io.observe(canvas);
const unobserveTheme = observeTheme(canvas, onThemeChange);
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
ro.disconnect();
io.disconnect();
unobserveTheme();
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerdown", onPointerDown);
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
};
}, [charset, cellSize, speed, mode, colorize, accent, interactive, reduceMotion]);
return (
<div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>
<canvas
ref={canvasRef}
aria-hidden="true"
className="pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100"
/>
{children != null && <div className="relative h-full w-full">{children}</div>}
</div>
);
}