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.
'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
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
| Prop | Type | Default | Description |
|---|---|---|---|
accent | string | "#8b5cf6" | Glow and glyph color. |
cell | number | 12 | Monospace cell size in px. Smaller means denser text. |
speed | number | 1 | Flow speed. 0 freezes it to a static frame. |
spread | number | 0.15 | Vertical tightness of the band, 0.08 to 0.3. Smaller is thinner. |
chars | string | " .:-=+*xsoX0#8@" | Character ramp, sparse to dense. |
className | string | - | Forwarded to the canvas. |