Background ASCII Wake

Background

Full-bleed monospace cursor-trail field: a sparse ambient scatter at rest, with the pointer dragging a decaying character comet whose length and brightness depend on how fast it moves.

Install
npx shadcn add https://design.helpmarq.com/r/background-ascii-wake.json
Source
registry/core/background-ascii-wake/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// WakeGlyph — a full-bleed monospace character-comet field. A sparse, static
// ambient scatter sits at rest; the pointer stamps a "heat" value into the
// cells it passes over, and each stamped cell decays back toward the ambient
// floor at a rate fixed at stamp time — a fast pass stamps many cells at a
// low, quick-decaying heat (a long, thin, short-lived wake), a slow pass
// stamps fewer cells at high heat with a slow decay rate (a fat, lingering
// blob). Direct-DOM rAF over a persistent Float32Array grid, glyph density
// mapped from an ASCII ramp, theme-aware ink read via getComputedStyle.
// ---------------------------------------------------------------------------

const RAMP = " .:-=+*#%@";

export interface WakeGlyphProps {
  /** grid cell size in px */
  cellSize?: number;
  className?: string;
}

// deterministic PRNG so the ambient scatter is stable within a session
function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a |= 0;
    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;
  };
}

export function WakeGlyph({ cellSize = 12, className = "" }: WakeGlyphProps) {
  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;
    let fg = getComputedStyle(canvas).color;
    const monoFont =
      getComputedStyle(document.documentElement).getPropertyValue("--font-mono") ||
      "ui-monospace, monospace";

    let dpr = 1;
    let cols = 0;
    let rows = 0;
    let heat: Float32Array = new Float32Array(0);
    let rate: Float32Array = new Float32Array(0);
    let ambient: Float32Array = new Float32Array(0);
    let raf = 0;
    let last = 0;
    const pointer = { x: -1, y: -1, t: 0, has: false };

    const resize = () => {
      dpr = Math.min(window.devicePixelRatio || 1, 2);
      const { width, height } = canvas.getBoundingClientRect();
      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);
      ctx.font = `${cellSize * 0.85}px ${monoFont}`;
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";

      cols = Math.max(1, Math.ceil(width / cellSize));
      rows = Math.max(1, Math.ceil(height / cellSize));
      const n = cols * rows;
      heat = new Float32Array(n);
      rate = new Float32Array(n).fill(1.4);
      ambient = new Float32Array(n);

      // sparse, near-silent ground state: a static seeded scatter of faint cells
      const rand = mulberry32(0xa53f9c1 ^ (cols * 71 + rows));
      for (let i = 0; i < n; i++) {
        if (rand() < 0.035) ambient[i] = 0.08 + rand() * 0.14;
      }
    };

    const draw = () => {
      const { width, height } = canvas.getBoundingClientRect();
      ctx.clearRect(0, 0, width, height);
      ctx.fillStyle = fg;
      for (let gy = 0; gy < rows; gy++) {
        for (let gx = 0; gx < cols; gx++) {
          const idx = gy * cols + gx;
          const lum = Math.max(ambient[idx]!, heat[idx]!);
          if (lum <= 0.03) continue;
          const ch = RAMP[Math.min(RAMP.length - 1, Math.floor(lum * (RAMP.length - 1)))];
          if (ch === " ") continue;
          ctx.globalAlpha = Math.min(1, 0.15 + lum * 0.85);
          ctx.fillText(ch, gx * cellSize + cellSize / 2, gy * cellSize + cellSize / 2);
        }
      }
      ctx.globalAlpha = 1;
    };

    // stamp a soft circular blot of heat around a grid cell, recording the
    // decay rate (higher = faster fade) that this pass's speed implies.
    const stamp = (px: number, py: number, radiusCells: number, decayRate: number) => {
      const cx = px / cellSize;
      const cy = py / cellSize;
      const r = Math.max(0.6, radiusCells);
      const minGx = Math.max(0, Math.floor(cx - r));
      const maxGx = Math.min(cols - 1, Math.ceil(cx + r));
      const minGy = Math.max(0, Math.floor(cy - r));
      const maxGy = Math.min(rows - 1, Math.ceil(cy + r));
      for (let gy = minGy; gy <= maxGy; gy++) {
        for (let gx = minGx; gx <= maxGx; gx++) {
          const d = Math.hypot(gx + 0.5 - cx, gy + 0.5 - cy);
          if (d > r) continue;
          const falloff = 1 - d / r;
          const idx = gy * cols + gx;
          if (falloff > heat[idx]!) {
            heat[idx] = falloff;
            rate[idx] = decayRate;
          }
        }
      }
    };

    const onPointerMove = (e: PointerEvent) => {
      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      const now = performance.now();
      if (pointer.has) {
        const dx = x - pointer.x;
        const dy = y - pointer.y;
        const dist = Math.hypot(dx, dy);
        const dt = Math.max(1, now - pointer.t);
        const speed = dist / dt; // px/ms

        // velocity-dependent stamp: fast = thin+quick-fading, slow = fat+lingering
        const radiusCells = Math.max(0.6, Math.min(2.6, 2.4 / (1 + speed * 1.6)));
        const decayRate = Math.max(0.8, Math.min(4.5, 1.0 + speed * 3.2));

        // sample along the path so a fast pass doesn't leave gaps between frames
        const steps = Math.max(1, Math.ceil(dist / (cellSize * 0.5)));
        for (let s = 1; s <= steps; s++) {
          const t = s / steps;
          stamp(pointer.x + dx * t, pointer.y + dy * t, radiusCells, decayRate);
        }
      } else {
        stamp(x, y, 1.4, 1.4);
      }
      pointer.x = x;
      pointer.y = y;
      pointer.t = now;
      pointer.has = true;
    };

    const onPointerLeave = () => {
      pointer.has = false;
    };

    const loop = (now: number) => {
      const dt = last ? Math.min(64, now - last) / 1000 : 1 / 60;
      last = now;
      for (let i = 0; i < heat.length; i++) {
        if (heat[i]! > 0) {
          heat[i]! -= rate[i]! * dt;
          if (heat[i]! < 0) heat[i] = 0;
        }
      }
      draw();
      raf = requestAnimationFrame(loop);
    };

    resize();
    if (reduced) {
      draw(); // ambient-only static frame; pointer wake is skipped entirely
    } else {
      canvas.addEventListener("pointermove", onPointerMove);
      canvas.addEventListener("pointerleave", onPointerLeave);
      raf = requestAnimationFrame(loop);
    }

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const onResize = () => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        resize();
        if (reduced) draw();
      }, 150);
    };
    window.addEventListener("resize", onResize);

    // the site's theme toggle flips a `.dark` class on <html> live, with no
    // remount — watch it so the glyph color updates without a page reload
    const themeObserver = new MutationObserver(() => {
      fg = getComputedStyle(canvas).color;
      if (reduced) draw();
    });
    themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    return () => {
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      themeObserver.disconnect();
      window.removeEventListener("resize", onResize);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerleave", onPointerLeave);
    };
  }, [cellSize]);

  return (
    <canvas
      ref={canvasRef}
      aria-hidden
      className={`block h-full w-full text-foreground ${className}`}
    />
  );
}
Use when

an ambient hero/background where the wake left by pointer movement itself is the effect. Reach for background-ascii-dither instead for a media-driven glyph field (image or flowing noise) with a simple cursor-proximity brighten rather than a persistent, per-cell decaying trail.

Build spec

Build <WakeGlyph cellSize? className?> as a full-bleed <canvas> over a persistent per-cell grid, not a stateless proximity glow. STATE: two parallel Float32Arrays sized cols*rows (cols/rows from container size divided by cellSize, default 12px) — `ambient`, a static seeded scatter generated once per resize (~3.5% of cells set to a low 0.08-0.22 value, deterministic per grid size via a small PRNG) representing the sparse, near-silent ground state, and `heat`/`rate`, the live wake: heat decays every frame by `rate * dt` per cell, where `rate` was fixed at the moment that specific cell was last stamped rather than being a single global constant. STAMPING: on pointermove, compute distance and elapsed time since the previous move to get a speed in px/ms, then derive both a stamp radius (in cells) and a decay rate from that speed with inverse relationships — fast movement yields a SMALL radius and a HIGH decay rate (many cells lit briefly and thinly along the path), slow movement yields a LARGE radius and a LOW decay rate (fewer stamps but each one fat and lingering) — this is the "per-cell decay with velocity dependence" the trail is built on. Because a fast pointer move can skip several grid cells between two consecutive pointermove events, the path between the previous and current point is sampled in sub-steps (spaced roughly cellSize/2 apart) and each sample stamps its own circular falloff blot, so the wake has no gaps at high speed. A stamp only raises a cell's heat (`Math.max`), never lowers it, and carries its rate along only when it does. RENDER: every frame, each cell's displayed luminance is `Math.max(ambient[i], heat[i])` mapped through the shared " .:-=+*#%@" density ramp exactly as background-ascii-dither does, with alpha scaled to luminance; cells at or below the ramp's blank threshold are skipped entirely rather than drawn as an empty glyph, which is what keeps a several-thousand-cell grid affordable to redraw every frame. Glyph ink is read once via getComputedStyle(canvas).color (theme-aware, never a hardcoded hex) and the canvas font uses the live --font-mono custom property rather than a literal family string. Window resize is debounced 150ms and regenerates the whole grid (new dimensions invalidate the old ambient/heat arrays outright — there is no cross-resize cell mapping). REDUCED MOTION: pointer listeners are never attached and no rAF loop starts; a single frame of the ambient scatter alone is painted once, so the component is inert but never blank or crashing.

Tags
backgroundasciicursorcanvastrail