ASCII Reveal
Reveals & Transitions

ASCII Reveal

A monospace character-density field that sharpens out of noise into a composed scene, resolving cell by cell in a deterministic wave.

Install

npx shadcn@latest add @paragon/ascii-reveal

ascii-reveal.tsx

"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>
  );
}