liten

Ascii Wave

A horizontal river of ASCII glyphs flowing through a value-noise field, white-hot at the core and dissolving into an accent bloom.

data stream

The glyphs scroll through a value-noise field on a plain canvas, so the motion is a genuine current rather than a per-frame flicker. In dark mode the glyphs are screen-blended light on a void; in light mode they sink to a deep accent on a pale page, hottest at the center. Freezes to a single static frame under reduced motion.

Installation

Complete the shared Setup first, then copy the component into components/ui/ascii-wave.tsx.

components/ui/ascii-wave.tsx
'use client';import * as React from 'react';import { cn } from '@/lib/cn';export type AsciiWaveProps = {  accent?: string;  cell?: number;  speed?: number;  spread?: number;  chars?: string;  className?: string;};function hash(x: number, y: number) {  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;  return n - Math.floor(n);}function noise(x: number, y: number) {  const xi = Math.floor(x);  const yi = Math.floor(y);  const xf = x - xi;  const yf = y - yi;  const u = xf * xf * (3 - 2 * xf);  const v = yf * yf * (3 - 2 * yf);  const tl = hash(xi, yi);  const tr = hash(xi + 1, yi);  const bl = hash(xi, yi + 1);  const br = hash(xi + 1, yi + 1);  return tl * (1 - u) * (1 - v) + tr * u * (1 - v) + bl * (1 - u) * v + br * u * v;}function toRgb(hex: string): [number, number, number] {  let h = hex.replace('#', '');  if (h.length === 3)    h = h      .split('')      .map((c) => c + c)      .join('');  const n = parseInt(h, 16);  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];}export function AsciiWave({  accent = '#8b5cf6',  cell = 12,  speed = 1,  spread = 0.15,  chars = ' .:-=+*xsoX0#8@',  className,}: AsciiWaveProps) {  const canvasRef = React.useRef<HTMLCanvasElement>(null);  const accentRef = React.useRef(accent);  accentRef.current = accent;  React.useEffect(() => {    const canvas = canvasRef.current;    if (!canvas) return;    const ctx = canvas.getContext('2d');    if (!ctx) return;    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;    const root = document.documentElement;    const ramp = chars;    const rampMax = ramp.length - 1;    let raf = 0;    let w = 0;    let h = 0;    let dpr = 1;    const resize = () => {      const rect = canvas.getBoundingClientRect();      dpr = Math.min(window.devicePixelRatio || 1, 2);      w = rect.width;      h = rect.height;      canvas.width = Math.max(1, Math.round(w * dpr));      canvas.height = Math.max(1, Math.round(h * dpr));      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);      ctx.textBaseline = 'top';      ctx.font = `${Math.round(cell)}px ui-monospace, SFMono-Regular, Menlo, monospace`;    };    const draw = (t: number) => {      const dark = root.classList.contains('dark');      const [ar, ag, ab] = toRgb(accentRef.current);      const cr = dark ? 245 : 76;      const cg = dark ? 243 : 29;      const cb = dark ? 255 : 149;      ctx.clearRect(0, 0, w, h);      ctx.globalCompositeOperation = dark ? 'lighter' : 'source-over';      const cols = Math.ceil(w / cell) + 1;      const rows = Math.ceil(h / cell) + 1;      for (let gx = 0; gx < cols; gx++) {        const px = gx * cell;        const nx = px / w - 0.5;        const edge = Math.min(1, Math.abs(nx) / 0.5);        const localSpread = spread * (0.5 + 1.9 * edge * edge);        const s2 = 2 * localSpread * localSpread;        const core = Math.exp(-(nx * nx) / (2 * 0.26 * 0.26));        for (let gy = 0; gy < rows; gy++) {          const py = gy * cell;          const ny = py / h - 0.5;          const band = Math.exp(-(ny * ny) / s2);          if (band < 0.04) continue;          let n = noise(gx * 0.16 - t * 0.9, gy * 0.34 + t * 0.16);          n = 0.6 * n + 0.4 * noise(gx * 0.4 + t * 0.25, gy * 0.5);          const level = Math.min(1, band * (0.55 + 0.6 * n));          if (level < 0.08) continue;          const white = band * core;          const k = white * white;          const r = Math.round(ar + (cr - ar) * k);          const gc = Math.round(ag + (cg - ag) * k);          const b = Math.round(ab + (cb - ab) * k);          let a = level;          if (!dark) a = Math.min(1, a * 1.3);          ctx.globalAlpha = a;          ctx.fillStyle = `rgb(${r},${gc},${b})`;          const g2 = noise(gx * 0.9 + 19.3 - t * 0.9, gy * 0.9 + 4.1);          const ci = Math.min(            rampMax,            Math.max(0, Math.round((0.5 * level + 0.55 * g2) * rampMax)),          );          const ch = ramp[ci];          if (ch !== ' ') ctx.fillText(ch, px, py);        }      }      ctx.globalAlpha = 1;      ctx.globalCompositeOperation = 'source-over';    };    resize();    if (reduce) {      draw(0);    } else {      let start: number | null = null;      const loop = (now: number) => {        if (start === null) start = now;        const t = ((now - start) / 1000) * speed;        draw(t);        raf = requestAnimationFrame(loop);      };      raf = requestAnimationFrame(loop);    }    const ro = new ResizeObserver(() => {      resize();      if (reduce) draw(0);    });    ro.observe(canvas);    const mo = new MutationObserver(() => {      if (reduce) draw(0);    });    mo.observe(root, { attributes: true, attributeFilter: ['class'] });    return () => {      cancelAnimationFrame(raf);      ro.disconnect();      mo.disconnect();    };  }, [cell, speed, spread, chars]);  return (    <canvas      ref={canvasRef}      aria-hidden      className={cn('pointer-events-none h-full w-full select-none', className)}    />  );}

Usage

Example.tsx
import { AsciiWave } from '@/components/ui/ascii-wave';

export default function Example() {
  return (
    <div className="relative h-80 w-full overflow-hidden rounded-xl border border-black/[0.08] bg-[#fafafa] dark:border-white/10 dark:bg-[#050507]">
      <AsciiWave />
    </div>
  );
}

Examples

A tighter, faster band in cyan.

Props

PropTypeDefaultDescription
accentstring"#8b5cf6"Glow and glyph color.
cellnumber12Monospace cell size in px. Smaller means denser text.
speednumber1Flow speed. 0 freezes it to a static frame.
spreadnumber0.15Vertical tightness of the band, 0.08 to 0.3. Smaller is thinner.
charsstring" .:-=+*xsoX0#8@"Character ramp, sparse to dense.
classNamestring-Forwarded to the canvas.
On this page0%