Craft / Canvas
ASCII wave field
Turn value noise into drifting ASCII clouds—no particles, no physics, just a scalar field remapped every frame.
Demo
InteractiveAnimated ASCII characters forming drifting wave clouds on a black background.
Field controls
Tune the lattice and how fast the warped field drifts.
This is the dark-mode landing background as a self-contained Canvas 2D component. Glyphs do not move with forces. Each frame samples a domain-warped FBM field and maps the value onto an ASCII ramp. Inspired by the ambient ASCII look of Portrait Reveal, implemented here without WebGL.
"use client";
import { useEffect, useRef } from "react";
const ASCII_RAMP = [" ", ".", ":", "-", "=", "+", "*", "%", "@"] as const;
function fract(n: number) {
return n - Math.floor(n);
}
function hash(x: number, y: number) {
let px = fract(x * 123.34);
let py = fract(y * 456.21);
const d = px * (px + 45.32) + py * (py + 45.32);
px += d;
py += d;
return fract(px * py);
}
function noise(x: number, y: number) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const a = hash(ix, iy);
const b = hash(ix + 1, iy);
const c = hash(ix, iy + 1);
const d = hash(ix + 1, iy + 1);
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
return a + (b - a) * ux + (c - a) * uy * (1 - ux) + (d - b) * ux * uy;
}
function fbm(x: number, y: number) {
let v = 0;
let amp = 0.5;
let px = x;
let py = y;
for (let i = 0; i < 4; i++) {
v += amp * noise(px, py);
px *= 2.03;
py *= 2.03;
amp *= 0.5;
}
return v;
}
function warpedField(u: number, v: number, t: number) {
const driftX = Math.sin(t * 0.05) * 1.3;
const driftY = Math.cos(t * 0.04) * 1.3;
const qX = fbm(u + t * 0.012, v + t * 0.012);
const qY = fbm(u + 3.1 + t * 0.009, v + 1.7 + t * 0.009);
const rX = fbm(u + 3 * qX + 1.2 + driftX, v + 3 * qY + 7.3 + driftY);
const rY = fbm(u + 3 * qX + 4.8 + driftX, v + 3 * qY + 2.1 + driftY);
return Math.min(1, Math.max(0, fbm(u + 3 * rX, v + 3 * rY)));
}
export function AsciiWaveField({
cellSize = 13,
speed = 1,
className,
}: {
cellSize?: number;
speed?: number;
className?: string;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
let width = 0;
let height = 0;
let frame = 0;
let started = 0;
const cell = Math.max(8, Math.min(28, cellSize));
const animate = speed > 0;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.floor(width * dpr));
canvas.height = Math.max(1, Math.floor(height * dpr));
context.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const paint = (now: number) => {
if (!started) started = now;
const t = motion.matches || !animate ? 0 : ((now - started) / 1000) * speed;
context.clearRect(0, 0, width, height);
context.fillStyle = "#000";
context.fillRect(0, 0, width, height);
context.font = `500 ${cell}px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace`;
context.textAlign = "center";
context.textBaseline = "middle";
const cols = Math.ceil(width / cell);
const rows = Math.ceil(height / cell);
const aspect = width / Math.max(height, 1);
const scaleX = aspect * 2.4;
const scaleY = 2.4;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const x = (col + 0.5) * cell;
const y = (row + 0.5) * cell;
const value = warpedField(
(x / width) * scaleX,
(y / height) * scaleY,
t,
);
const level = Math.min(
ASCII_RAMP.length - 1,
Math.max(0, Math.round(value * (ASCII_RAMP.length - 1))),
);
const glyph = ASCII_RAMP[level];
if (glyph === " ") continue;
const density = level / (ASCII_RAMP.length - 1);
const ridge = density * density;
context.fillStyle = ridge > 0.35 ? "#ffffff" : "#b8becc";
context.globalAlpha = 0.05 + ridge * 0.78;
context.fillText(glyph, x, y);
}
}
context.globalAlpha = 1;
if (animate && !motion.matches) frame = requestAnimationFrame(paint);
};
const restart = () => {
cancelAnimationFrame(frame);
started = 0;
resize();
frame = requestAnimationFrame(paint);
};
restart();
window.addEventListener("resize", resize);
motion.addEventListener("change", restart);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", resize);
motion.removeEventListener("change", restart);
};
}, [cellSize, speed]);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={className ? `block size-full ${className}` : "block size-full"}
/>
);
}The live demo adds staged tutorial layers and playground chrome. Those are for teaching; the block above is the full ambient effect.