asanshay
componentascii-wavesource: ok · hit
ASCII Wave
A flowing ASCII animation with wave-based coloring. Inspired by exa.ai.
npx shadcn add "https://ds.asanshay.com/r/ascii-wave.json"free
No selection tags yet — empty means no evidence, not lack of support.
Files1 · source cached 10/8/2026, 6:25:03 PM
registry/new-york/blocks/ascii-wave/ascii-wave.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface AsciiWaveProps {
/** Path to the left mask image */
leftImage?: string;
/** Path to the right mask image */
rightImage?: string;
/** Character set to use for the wave animation */
chars?: string;
/** Cell width in pixels (default: 7) */
cellWidth?: number;
/** Cell height in pixels (default: 6) */
cellHeight?: number;
/** Font size in pixels (default: 6) */
fontSize?: number;
/** Animation speed multiplier (default: 1) */
speed?: number;
/** Opacity of the canvas (default: 0.9) */
opacity?: number;
/** Additional CSS classes */
className?: string;
/** Inline styles */
style?: React.CSSProperties;
/** Children to render on top of the background */
children?: React.ReactNode;
}
// Color gradient - deep blue through cyan to light blue
function getWaveColor(t: number): { r: number; g: number; b: number } {
if (t <= 0.14) {
const e = t / 0.14;
return { r: 0, g: 17 + 3 * e, b: 159 + 5 * e };
}
if (t <= 0.29) {
const e = (t - 0.14) / 0.15;
return { r: 25 * e, g: 20 + 38 * e, b: 164 + 78 * e };
}
if (t <= 0.41) {
const e = (t - 0.29) / 0.12;
return { r: 25 + 36 * e, g: 58 + 54 * e, b: 242 + 9 * e };
}
if (t <= 0.53) {
const e = (t - 0.41) / 0.12;
return { r: 61 + 86 * e, g: 112 + 81 * e, b: 251 };
}
const e = (t - 0.53) / 0.47;
return { r: 147 + 108 * e, g: 193 + 62 * e, b: 251 + 4 * e };
}
// Color dodge blend mode
function colorDodge(base: number, blend: number): number {
const b = base / 255;
const l = blend / 255;
if (l === 0) return 0;
if (l === 1) return base;
return Math.max(0, Math.min(255, Math.round(255 * (1 - (1 - b) / l))));
}
export function AsciiWave({
leftImage = "/images/backgrounds/mountain-l-color.png",
rightImage = "/images/backgrounds/mountain-r-color.png",
chars = "▅▃▁?ab018:. ",
cellWidth = 7,
cellHeight = 6,
fontSize = 6,
speed = 1,
opacity = 0.9,
className,
style,
children,
}: AsciiWaveProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const animationRef = React.useRef<number>(0);
const startTimeRef = React.useRef<number>(0);
// Image refs
const leftImageRef = React.useRef<HTMLImageElement | null>(null);
const rightImageRef = React.useRef<HTMLImageElement | null>(null);
const leftImageDataRef = React.useRef<ImageData | null>(null);
const rightImageDataRef = React.useRef<ImageData | null>(null);
const { sin, floor, PI } = Math;
const TAU = 2 * PI;
// Generate ASCII character based on position and time
const getChar = React.useCallback(
(
pos: { x: number; y: number },
state: { time: number; cols: number; rows: number }
): string => {
const time = 1e-4 * state.time * speed;
const scale = Math.min(state.cols, state.rows);
const normalized = {
x: (2 * (pos.x - state.cols / 2)) / scale,
y: (2 * (pos.y - state.rows / 2)) / scale,
};
const index =
floor(
(0.5 * sin(4 * (normalized.y - 0.5 * time)) +
0.5 +
0.1 * sin(3 * normalized.x)) *
chars.length
) % chars.length;
return chars[Math.abs(index)];
},
[chars, speed, sin, floor]
);
// Main render loop
const render = React.useCallback(
(timestamp: number) => {
if (!canvasRef.current) return;
if (!startTimeRef.current) startTimeRef.current = timestamp;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const screenWidth = rect.width;
const screenHeight = rect.height;
const dpr = window.devicePixelRatio || 1;
canvas.width = screenWidth * dpr;
canvas.height = screenHeight * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = "transparent";
ctx.fillRect(0, 0, screenWidth, screenHeight);
const cols = Math.floor(screenWidth / cellWidth);
const rows = Math.floor(screenHeight / cellHeight);
const state = {
time: timestamp - startTimeRef.current,
cols,
rows,
};
// Calculate layouts for both images
type ImageLayout = {
x: number;
y: number;
width: number;
height: number;
};
let leftLayout: ImageLayout | null = null;
let rightLayout: ImageLayout | null = null;
// Left image layout - fill height, anchor to left edge
if (leftImageRef.current) {
const img = leftImageRef.current;
const imgAspect = img.width / img.height;
const drawHeight = screenHeight;
const drawWidth = drawHeight * imgAspect;
leftLayout = {
x: 0,
y: 0,
width: drawWidth,
height: drawHeight,
};
}
// Right image layout - fill height, anchor to right edge
if (rightImageRef.current) {
const img = rightImageRef.current;
const imgAspect = img.width / img.height;
const drawHeight = screenHeight;
const drawWidth = drawHeight * imgAspect;
rightLayout = {
x: screenWidth - drawWidth,
y: 0,
width: drawWidth,
height: drawHeight,
};
}
// Draw images
ctx.save();
ctx.globalAlpha = 0.4;
if (leftImageRef.current && leftLayout) {
ctx.drawImage(
leftImageRef.current,
leftLayout.x,
leftLayout.y,
leftLayout.width,
leftLayout.height
);
}
if (rightImageRef.current && rightLayout) {
ctx.drawImage(
rightImageRef.current,
rightLayout.x,
rightLayout.y,
rightLayout.width,
rightLayout.height
);
}
ctx.restore();
// Apply top gradient fade
ctx.save();
const gradient = ctx.createLinearGradient(0, 0, 0, screenHeight);
gradient.addColorStop(0, "rgba(255, 255, 255, 1)");
gradient.addColorStop(0.15, "rgba(255, 255, 255, 0)");
gradient.addColorStop(1, "rgba(255, 255, 255, 0)");
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, screenWidth, screenHeight);
ctx.restore();
// Setup text rendering
ctx.font = `${fontSize}px monospace`;
ctx.textAlign = "left";
ctx.textBaseline = "top";
const baseColor = { r: 231, g: 235, b: 237 };
// Render ASCII grid
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const char = getChar({ x: col, y: row }, state);
const normalizedRow = row / rows;
let charOpacity = 0;
const pixelX = cellWidth * col;
const pixelY = cellHeight * row;
// Sample left image
if (leftImageRef.current && leftImageDataRef.current && leftLayout) {
const img = leftImageRef.current;
if (
pixelX >= leftLayout.x &&
pixelX <= leftLayout.x + leftLayout.width &&
pixelY >= leftLayout.y &&
pixelY <= leftLayout.y + leftLayout.height
) {
const sampleX = Math.floor(
((pixelX - leftLayout.x) / leftLayout.width) * img.width
);
const sampleY = Math.floor(
((pixelY - leftLayout.y) / leftLayout.height) * img.height
);
if (
sampleX >= 0 &&
sampleX < img.width &&
sampleY >= 0 &&
sampleY < img.height
) {
const pixelIndex = (sampleY * img.width + sampleX) * 4;
const r = leftImageDataRef.current.data[pixelIndex];
const g = leftImageDataRef.current.data[pixelIndex + 1];
const b = leftImageDataRef.current.data[pixelIndex + 2];
const luminance = (r + g + b) / 3;
if (luminance >= 230) {
charOpacity = 0;
} else if (luminance >= 200) {
charOpacity = Math.pow(1 - (luminance - 200) / 30, 2);
} else {
charOpacity = Math.pow(1 - luminance / 200, 0.3);
}
}
}
}
// Sample right image
if (
rightImageRef.current &&
rightImageDataRef.current &&
rightLayout
) {
const img = rightImageRef.current;
if (
pixelX >= rightLayout.x &&
pixelX <= rightLayout.x + rightLayout.width &&
pixelY >= rightLayout.y &&
pixelY <= rightLayout.y + rightLayout.height
) {
const sampleX = Math.floor(
((pixelX - rightLayout.x) / rightLayout.width) * img.width
);
const sampleY = Math.floor(
((pixelY - rightLayout.y) / rightLayout.height) * img.height
);
if (
sampleX >= 0 &&
sampleX < img.width &&
sampleY >= 0 &&
sampleY < img.height
) {
const pixelIndex = (sampleY * img.width + sampleX) * 4;
const r = rightImageDataRef.current.data[pixelIndex];
const g = rightImageDataRef.current.data[pixelIndex + 1];
const b = rightImageDataRef.current.data[pixelIndex + 2];
const luminance = (r + g + b) / 3;
if (luminance >= 230) {
charOpacity = Math.max(charOpacity, 0);
} else if (luminance >= 200) {
charOpacity = Math.max(
charOpacity,
Math.pow(1 - (luminance - 200) / 30, 2)
);
} else {
charOpacity = Math.max(
charOpacity,
Math.pow(1 - luminance / 200, 0.3)
);
}
}
}
}
// Fade at edges (top and bottom)
let edgeFade = 1;
if (normalizedRow < 0.1) {
edgeFade = normalizedRow / 0.1;
} else if (normalizedRow > 0.9) {
edgeFade = (1 - normalizedRow) / 0.1;
}
charOpacity *= edgeFade;
// Only render if visible
if (charOpacity > 0.025) {
const waveValue = 0.5 * sin(3 * normalizedRow * TAU) + 0.5;
const waveColor = getWaveColor(waveValue);
const blended = {
r: colorDodge(baseColor.r, waveColor.r),
g: colorDodge(baseColor.g, waveColor.g),
b: colorDodge(baseColor.b, waveColor.b),
};
const finalColor = {
r: Math.round(baseColor.r + (blended.r - baseColor.r) * 0.8),
g: Math.round(baseColor.g + (blended.g - baseColor.g) * 0.8),
b: Math.round(baseColor.b + (blended.b - baseColor.b) * 0.8),
};
ctx.fillStyle = `rgba(${finalColor.r}, ${finalColor.g}, ${finalColor.b}, ${charOpacity})`;
ctx.fillText(char, cellWidth * col, cellHeight * row);
}
}
}
animationRef.current = requestAnimationFrame(render);
},
[getChar, cellWidth, cellHeight, fontSize, sin, TAU]
);
// Load left image
React.useEffect(() => {
if (!leftImage) return;
const img = new Image();
img.src = leftImage;
img.onload = () => {
leftImageRef.current = img;
const tempCanvas = document.createElement("canvas");
tempCanvas.width = img.width;
tempCanvas.height = img.height;
const tempCtx = tempCanvas.getContext("2d");
if (tempCtx) {
tempCtx.drawImage(img, 0, 0);
try {
leftImageDataRef.current = tempCtx.getImageData(
0,
0,
img.width,
img.height
);
} catch (e) {
console.error("Failed to extract left image data:", e);
}
}
};
}, [leftImage]);
// Load right image
React.useEffect(() => {
if (!rightImage) return;
const img = new Image();
img.src = rightImage;
img.onload = () => {
rightImageRef.current = img;
const tempCanvas = document.createElement("canvas");
tempCanvas.width = img.width;
tempCanvas.height = img.height;
const tempCtx = tempCanvas.getContext("2d");
if (tempCtx) {
tempCtx.drawImage(img, 0, 0);
try {
rightImageDataRef.current = tempCtx.getImageData(
0,
0,
img.width,
img.height
);
} catch (e) {
console.error("Failed to extract right image data:", e);
}
}
};
}, [rightImage]);
// Start animation
React.useEffect(() => {
if (!canvasRef.current) return;
animationRef.current = requestAnimationFrame(render);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [render]);
// Handle resize
React.useEffect(() => {
const handleResize = () => {
startTimeRef.current = 0;
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<div
ref={containerRef}
className={cn("relative w-full h-full overflow-hidden", className)}
style={style}
>
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ opacity }}
/>
{children && <div className="relative z-10 h-full">{children}</div>}
</div>
);
}