Skip to main content

ns-ui

Background ASCII Wake

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.

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.

Install

npx shadcn add https://design.helpmarq.com/r/background-ascii-wake.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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;
  /** extra classes merged onto the rendered root element */
  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}`}
    />
  );
}
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.

Props

PropTypeDefaultDescription
cellSize?number12grid cell size in px
className?stringextra classes merged onto the rendered root element