A monospace character-density field that sharpens out of noise into a composed scene, resolving cell by cell in a deterministic wave.
npx shadcn@latest add @paragon/ascii-reveal"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/* ----------------------------------------------------------- deterministic */
/** Small deterministic PRNG so the resolve order is stable across renders. */
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function hashString(input: string): number {
let h = 0;
for (let i = 0; i < input.length; i++) {
h = (Math.imul(31, h) + input.charCodeAt(i)) | 0;
}
return h;
}
export type AsciiRevealResolve = "scan" | "center" | "up" | "noise";
export interface AsciiRevealProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** Headline drawn into the scene and resolved by the characters. */
text?: string;
/** Secondary line under the headline. Pass "" to hide. */
subtext?: string;
/**
* Character ramp, sparse → dense. The first char is "empty", the last is the
* brightest. Deterministically indexed by per-cell luminance.
*/
ramp?: string;
/** Monospace cell height in px (glyphs are ~0.6× as wide). Smaller = finer. */
cellSize?: number;
/** Character color. Defaults to the foreground token. */
color?: string;
/** Accent color for the brightest characters. Defaults to #4D80E6. */
accent?: string;
/** Total resolve duration, in ms. */
duration?: number;
/** Order the field sharpens in. */
resolve?: AsciiRevealResolve;
/** Seed for the deterministic noise field. */
seed?: number;
/** How the reveal is triggered. */
trigger?: "view" | "hover" | "click";
}
const DEFAULT_RAMP = " .:-=+*#%@";
/**
* AsciiReveal — a monospace character-density field that sharpens from noise
* into a composed scene. A gradient panel plus a headline are drawn to an
* offscreen canvas and sampled into a per-cell luminance grid; each cell maps
* its luminance to a glyph on a sparse→dense ramp. On reveal, every cell's
* luminance interpolates from a seeded random value toward its true value on a
* per-cell delay, so the picture resolves out of static in a deterministic
* wave — scan, center, upward, or pure noise.
*
* The visible canvas paints characters in the foreground color (brightest cells
* tinted with an accent) over a soft gradient wash, and it draws its first
* (noise) frame in a layout effect so nothing flashes before the engine takes
* over. The loop pauses offscreen and resumes (IntersectionObserver), re-samples
* on resize, and re-resolves token colors when the theme flips. Triggers: `view`
* runs once in view; `hover` opens on hover and dissolves back on leave
* (interruptible), falling back to `view` on touch; `click` is keyboard-operable
* (Enter/Space). Under `prefers-reduced-motion` the resolved scene is drawn at
* once. Deterministic — no Math.random in render.
*/
export function AsciiReveal({
text = "SHIP IT",
subtext = "deploy · preview · merge",
ramp = DEFAULT_RAMP,
cellSize = 12,
color = "var(--color-foreground)",
accent = "#4D80E6",
duration = 1400,
resolve = "scan",
seed,
trigger = "view",
className,
onClick,
onKeyDown,
onPointerEnter,
onPointerLeave,
...props
}: AsciiRevealProps) {
const reactId = React.useId();
const resolvedSeed = seed ?? hashString(reactId + text + subtext);
const wrapperRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [fine, setFine] = React.useState(false);
const [clicked, setClicked] = React.useState(false);
// Progress + target survive engine rebuilds so prop changes resume mid-flight.
const progressRef = React.useRef(0);
const targetRef = React.useRef(0);
const kickRef = React.useRef<() => void>(() => {});
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setFine(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
const effectiveTrigger = trigger === "hover" && !fine ? "view" : trigger;
React.useLayoutEffect(() => {
const wrapper = wrapperRef.current;
const canvas = canvasRef.current;
if (!wrapper || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const chars = ramp.length > 1 ? ramp : DEFAULT_RAMP;
let width = 0;
let height = 0;
let cols = 0;
let rows = 0;
let cw = 0; // cell width
let lum: Float32Array = new Float32Array(0); // target luminance per cell 0..1
let start: Float32Array = new Float32Array(0); // seeded noise start per cell
let delay: Float32Array = new Float32Array(0); // per-cell reveal delay 0..1
let fg = color;
let ac = accent;
let rafId: number | null = null;
let last = 0;
let visible = true;
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t);
const resolveColors = () => {
canvas.style.color = color;
fg = getComputedStyle(canvas).color || color;
canvas.style.color = accent;
ac = getComputedStyle(canvas).color || accent;
canvas.style.color = "";
};
/** Draw the target scene offscreen and sample luminance into `lum`. */
const sample = () => {
cw = cellSize * 0.6;
cols = Math.max(1, Math.floor(width / cw));
rows = Math.max(1, Math.floor(height / cellSize));
const off = document.createElement("canvas");
off.width = cols;
off.height = rows;
const octx = off.getContext("2d", { willReadFrequently: true });
if (!octx) return;
// A soft radial gradient field gives the picture tonal structure so the
// resolved image is more than just the headline.
const g = octx.createRadialGradient(
cols * 0.32,
rows * 0.3,
0,
cols * 0.5,
rows * 0.5,
Math.max(cols, rows) * 0.75,
);
g.addColorStop(0, "rgba(255,255,255,0.95)");
g.addColorStop(0.55, "rgba(255,255,255,0.35)");
g.addColorStop(1, "rgba(255,255,255,0.06)");
octx.fillStyle = g;
octx.fillRect(0, 0, cols, rows);
// The headline + subtext, rendered white so bright pixels → dense glyphs.
octx.fillStyle = "#fff";
octx.textAlign = "center";
const cx = cols / 2;
if (text) {
let fs = Math.min(rows * 0.34, (cols / Math.max(1, text.length)) * 1.5);
octx.textBaseline = "middle";
octx.font = `700 ${fs}px ui-sans-serif, system-ui, sans-serif`;
while (octx.measureText(text).width > cols * 0.86 && fs > 3) {
fs -= 0.5;
octx.font = `700 ${fs}px ui-sans-serif, system-ui, sans-serif`;
}
octx.fillText(text, cx, subtext ? rows * 0.44 : rows * 0.5);
}
if (subtext) {
let ss = Math.min(rows * 0.12, (cols / Math.max(1, subtext.length)) * 1.4);
octx.font = `500 ${ss}px ui-sans-serif, system-ui, sans-serif`;
while (octx.measureText(subtext).width > cols * 0.8 && ss > 2) {
ss -= 0.5;
octx.font = `500 ${ss}px ui-sans-serif, system-ui, sans-serif`;
}
octx.fillText(subtext, cx, rows * 0.66);
}
const data = octx.getImageData(0, 0, cols, rows).data;
const n = cols * rows;
lum = new Float32Array(n);
start = new Float32Array(n);
delay = new Float32Array(n);
const rand = mulberry32(resolvedSeed);
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const i = y * cols + x;
const p = i * 4;
// Rec.601 luma, weighted by alpha.
const a = data[p + 3] / 255;
const l =
((0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2]) /
255) *
a;
lum[i] = l;
start[i] = rand(); // static-noise starting brightness
// Base ordering by resolve mode, jittered so the wavefront is organic.
const fx = cols <= 1 ? 0 : x / (cols - 1);
const fy = rows <= 1 ? 0 : y / (rows - 1);
let base: number;
switch (resolve) {
case "center":
base = clamp01(Math.hypot(fx - 0.5, fy - 0.5) / 0.7071);
break;
case "up":
base = 1 - fy;
break;
case "noise":
base = rand();
break;
case "scan":
default:
base = fx;
break;
}
delay[i] = clamp01(base * 0.72 + rand() * 0.28);
}
}
};
const paint = () => {
const p = progressRef.current;
ctx.clearRect(0, 0, width, height);
ctx.font = `${cellSize}px ${"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const half = chars.length - 1;
for (let y = 0; y < rows; y++) {
const py = y * cellSize + cellSize / 2;
for (let x = 0; x < cols; x++) {
const i = y * cols + x;
// Local progress: this cell only starts resolving after its delay,
// and finishes within a fixed window — a travelling wavefront.
const win = 0.42;
const local = clamp01((p - delay[i] * (1 - win)) / win);
const e = easeOutCubic(local);
// Blend seeded noise → true luminance as the cell resolves.
const value = start[i] * (1 - e) + lum[i] * e;
if (value <= 0.04) continue;
const idx = Math.min(half, Math.max(0, Math.round(value * half)));
const ch = chars[idx];
if (ch === " ") continue;
// Brightest glyphs pick up the accent; everything else the fg color.
// Unresolved cells sit dim so the static reads as background hum.
const bright = value > 0.72 && e > 0.5;
ctx.globalAlpha = 0.25 + 0.75 * (e * 0.7 + value * 0.3);
ctx.fillStyle = bright ? ac : fg;
ctx.fillText(ch, x * cw + cw / 2, py);
}
}
ctx.globalAlpha = 1;
};
const tick = (now: number) => {
rafId = null;
const dt = last ? Math.min(64, now - last) : 1000 / 60;
last = now;
const step = dt / Math.max(1, duration);
const target = targetRef.current;
const pcur = progressRef.current;
const next = pcur + Math.sign(target - pcur) * step;
progressRef.current =
(target - pcur) * (target - next) <= 0 ? target : next;
paint();
if (progressRef.current !== targetRef.current && visible) {
rafId = requestAnimationFrame(tick);
} else {
last = 0;
}
};
const kick = () => {
if (reduceMotion.matches) {
progressRef.current = 1;
targetRef.current = 1;
paint();
return;
}
if (
rafId === null &&
visible &&
progressRef.current !== targetRef.current
) {
last = 0;
rafId = requestAnimationFrame(tick);
}
};
kickRef.current = kick;
const resize = () => {
const rect = wrapper.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
resolveColors();
sample();
paint();
};
if (reduceMotion.matches) {
progressRef.current = 1;
targetRef.current = 1;
}
resize();
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(wrapper);
const intersectionObserver = new IntersectionObserver(
([entry]) => {
if (!entry) return;
visible = entry.isIntersecting;
if (!visible) {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
last = 0;
}
return;
}
if (effectiveTrigger === "view" && entry.intersectionRatio >= 0.3) {
targetRef.current = 1;
}
kick();
},
{ threshold: [0, 0.3] },
);
intersectionObserver.observe(wrapper);
const themeObserver = new MutationObserver(() => {
resolveColors();
paint();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
reduceMotion.addEventListener("change", kick);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
resizeObserver.disconnect();
intersectionObserver.disconnect();
themeObserver.disconnect();
reduceMotion.removeEventListener("change", kick);
};
}, [
text,
subtext,
ramp,
cellSize,
color,
accent,
duration,
resolve,
resolvedSeed,
effectiveTrigger,
]);
const arm = React.useCallback(() => {
targetRef.current = 1;
kickRef.current();
}, []);
const disarm = React.useCallback(() => {
targetRef.current = 0;
kickRef.current();
}, []);
const awaitingClick = effectiveTrigger === "click" && !clicked;
return (
<div
ref={wrapperRef}
data-slot="ascii-reveal"
className={cn("relative overflow-hidden", className)}
role={awaitingClick ? "button" : undefined}
tabIndex={awaitingClick ? 0 : undefined}
aria-label={awaitingClick ? "Reveal content" : undefined}
onPointerEnter={(e) => {
onPointerEnter?.(e);
if (effectiveTrigger === "hover") arm();
}}
onPointerLeave={(e) => {
onPointerLeave?.(e);
if (effectiveTrigger === "hover") disarm();
}}
onClick={(e) => {
onClick?.(e);
if (effectiveTrigger === "click") {
setClicked(true);
arm();
}
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (awaitingClick && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setClicked(true);
arm();
}
}}
{...props}
>
{/* The resolved text lives in the accessible tree; the canvas is decor. */}
<span className="sr-only">
{text}
{subtext ? ` — ${subtext}` : ""}
</span>
<canvas
ref={canvasRef}
aria-hidden
className="pointer-events-none block size-full"
/>
</div>
);
}