Delicate Ascii Dots Effect
An interactive React component that adds a dynamic bubble effect, visually tracking cursor movement in real time.
An interactive React component that adds a dynamic bubble effect, visually tracking cursor movement in real time.
1'use client';23import { useEffect, useRef, useCallback } from 'react';45interface DelicateAsciiDotsProps {6backgroundColor?: string;7textColor?: string;8gridSize?: number;9removeWaveLine?: boolean;10animationSpeed?: number;11}1213interface Wave {14x: number;15y: number;16frequency: number;17amplitude: number;18phase: number;19speed: number;20}2122interface GridCell {23char: string;24opacity: number;25}2627const DelicateAsciiDots = ({28backgroundColor = '#000000',29textColor = '85, 85, 85',30gridSize = 80,31removeWaveLine = true,32animationSpeed = 0.75,33}: DelicateAsciiDotsProps) => {34const canvasRef = useRef<HTMLCanvasElement>(null);35const containerRef = useRef<HTMLDivElement>(null);36const mouseRef = useRef({ x: 0, y: 0, isDown: false });37const wavesRef = useRef<Wave[]>([]);38const timeRef = useRef<number>(0);39const animationFrameId = useRef<number | null>(null);40const clickWaves = useRef<41Array<{ x: number; y: number; time: number; intensity: number }>42>([]);43const dimensionsRef = useRef({ width: 0, height: 0 });4445const CHARS =46'⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿⠁⠂⠄⠈⠐⠠⡀⢀⠃⠅⠘⠨⠊⠋⠌⠍⠎⠏⠑⠒⠓⠔⠕⠖⠗⠙⠚⠛⠜⠝⠞⠟⠡⠢⠣⠤⠥⠦⠧⠩⠪⠫⠬⠭⠮⠯⠱⠲⠳⠴⠵⠶⠷⠹⠺⠻⠼⠽⠾⠿⡁⡂⡃⡄⡅⡆⡇⡉⡊⡋⡌⡍⡎⡏⡑⡒⡓⡔⡕⡖⡗⡙⡚⡛⡜⡝⡞⡟⡡⡢⡣⡤⡥⡦⡧⡩⡪⡫⡬⡭⡮⡯⡱⡲⡳⡴⡵⡶⡷⡹⡺⡻⡼⡽⡾⡿⢁⢂⢃⢄⢅⢆⢇⢉⢊⢋⢌⢍⢎⢏⢑⢒⢓⢔⢕⢖⢗⢙⢚⢛⢜⢝⢞⢟⢡⢢⢣⢤⢥⢦⢧⢩⢪⢫⢬⢭⢮⢯⢱⢲⢳⢴⢵⢶⢷⢹⢺⢻⢼⢽⢾⢿⣀⣁⣂⣃⣄⣅⣆⣇⣉⣊⣋⣌⣍⣎⣏⣑⣒⣓⣔⣕⣖⣗⣙⣚⣛⣜⣝⣞⣟⣡⣢⣣⣤⣥⣦⣧⣩⣪⣫⣬⣭⣮⣯⣱⣲⣳⣴⣵⣶⣷⣹⣺⣻⣼⣽⣾⣿';4748const resizeCanvas = useCallback(() => {49const canvas = canvasRef.current;50const container = containerRef.current;51if (!canvas || !container) return;5253const containerRect = container.getBoundingClientRect();54const width = containerRect.width;55const height = containerRect.height;5657// Store dimensions for coordinate calculations58dimensionsRef.current = { width, height };5960const dpr = window.devicePixelRatio || 1;6162// Set canvas size to match container63canvas.width = width * dpr;64canvas.height = height * dpr;6566canvas.style.width = width + 'px';67canvas.style.height = height + 'px';6869const ctx = canvas.getContext('2d');70if (ctx) {71ctx.scale(dpr, dpr);72}73}, []);7475const handleMouseMove = useCallback((e: MouseEvent) => {76const canvas = canvasRef.current;77if (!canvas) return;7879const rect = canvas.getBoundingClientRect();80const x = e.clientX - rect.left;81const y = e.clientY - rect.top;8283mouseRef.current = {84x: x,85y: y,86isDown: mouseRef.current.isDown,87};88}, []);8990const handleMouseDown = useCallback(91(e: MouseEvent) => {92mouseRef.current.isDown = true;93const canvas = canvasRef.current;94if (!canvas) return;9596const rect = canvas.getBoundingClientRect();97const x = e.clientX - rect.left;98const y = e.clientY - rect.top;99100// Convert screen coordinates to grid coordinates101const { width, height } = dimensionsRef.current;102const cellWidth = width / gridSize;103const cellHeight = height / gridSize;104105const gridX = x / cellWidth;106const gridY = y / cellHeight;107108clickWaves.current.push({109x: gridX,110y: gridY,111time: Date.now(),112intensity: 2,113});114115// Clean up old waves116const now = Date.now();117clickWaves.current = clickWaves.current.filter(118(wave) => now - wave.time < 4000119);120},121[gridSize]122);123124const handleMouseUp = useCallback(() => {125mouseRef.current.isDown = false;126}, []);127128const getClickWaveInfluence = (129x: number,130y: number,131currentTime: number132): number => {133let totalInfluence = 0;134135clickWaves.current.forEach((wave) => {136const age = currentTime - wave.time;137const maxAge = 4000;138if (age < maxAge) {139const dx = x - wave.x;140const dy = y - wave.y;141const distance = Math.sqrt(dx * dx + dy * dy);142const waveRadius = (age / maxAge) * gridSize * 0.8;143const waveWidth = gridSize * 0.15;144145if (Math.abs(distance - waveRadius) < waveWidth) {146const waveStrength = (1 - age / maxAge) * wave.intensity;147const proximityToWave =1481 - Math.abs(distance - waveRadius) / waveWidth;149totalInfluence +=150waveStrength *151proximityToWave *152Math.sin((distance - waveRadius) * 0.5);153}154}155});156157return totalInfluence;158};159160const animate = useCallback(() => {161const canvas = canvasRef.current;162if (!canvas) return;163164const ctx = canvas.getContext('2d');165if (!ctx) return;166167const currentTime = Date.now();168timeRef.current += animationSpeed * 0.016;169170const { width, height } = dimensionsRef.current;171if (width === 0 || height === 0) return;172173// Clear canvas174ctx.fillStyle = backgroundColor;175ctx.fillRect(0, 0, width, height);176177const newGrid: (GridCell | null)[][] = Array(gridSize)178.fill(0)179.map(() => Array(gridSize).fill(null));180181// Calculate cell dimensions182const cellWidth = width / gridSize;183const cellHeight = height / gridSize;184185// Convert mouse position to grid coordinates186const mouseGridX = mouseRef.current.x / cellWidth;187const mouseGridY = mouseRef.current.y / cellHeight;188189// Create mouse wave190const mouseWave: Wave = {191x: mouseGridX,192y: mouseGridY,193frequency: 0.3,194amplitude: 1,195phase: timeRef.current * 2,196speed: 1,197};198199// Calculate wave interference200for (let y = 0; y < gridSize; y++) {201for (let x = 0; x < gridSize; x++) {202let totalWave = 0;203204// Sum all wave contributions205const allWaves = wavesRef.current.concat([mouseWave]);206207allWaves.forEach((wave) => {208const dx = x - wave.x;209const dy = y - wave.y;210const dist = Math.sqrt(dx * dx + dy * dy);211const falloff = 1 / (1 + dist * 0.1);212const value =213Math.sin(214dist * wave.frequency - timeRef.current * wave.speed + wave.phase215) *216wave.amplitude *217falloff;218219totalWave += value;220});221222// Add click wave influence223const clickInfluence = getClickWaveInfluence(x, y, currentTime);224totalWave += clickInfluence;225226// Enhanced mouse interaction227const mouseDistance = Math.sqrt(228(x - mouseGridX) ** 2 + (y - mouseGridY) ** 2229);230if (mouseDistance < gridSize * 0.3) {231const mouseEffect = (1 - mouseDistance / (gridSize * 0.3)) * 0.8;232totalWave += mouseEffect * Math.sin(timeRef.current * 3);233}234235// Map interference pattern to characters and opacity236const normalizedWave = (totalWave + 2) / 4;237if (Math.abs(totalWave) > 0.2) {238const charIndex = Math.min(239CHARS.length - 1,240Math.max(0, Math.floor(normalizedWave * (CHARS.length - 1)))241);242const opacity = Math.min(2430.9,244Math.max(0.4, 0.4 + normalizedWave * 0.5)245);246247newGrid[y][x] = {248char: CHARS[charIndex] || CHARS[0],249opacity: opacity,250};251}252}253}254255// Calculate optimal font size256const fontSize = Math.min(cellWidth, cellHeight) * 0.8;257ctx.font = `${fontSize}px monospace`;258ctx.textAlign = 'center';259ctx.textBaseline = 'middle';260261// Draw characters262for (let y = 0; y < gridSize; y++) {263for (let x = 0; x < gridSize; x++) {264const cell = newGrid[y][x];265if (cell && cell.char && CHARS.includes(cell.char)) {266ctx.fillStyle = `rgba(${textColor}, ${cell.opacity})`;267ctx.fillText(268cell.char,269x * cellWidth + cellWidth / 2,270y * cellHeight + cellHeight / 2271);272}273}274}275276// Draw click wave effects (visual ripples)277if (!removeWaveLine) {278clickWaves.current.forEach((wave) => {279const age = currentTime - wave.time;280const maxAge = 4000;281if (age < maxAge) {282const progress = age / maxAge;283const radius = progress * Math.min(width, height) * 0.5;284const alpha = (1 - progress) * 0.3 * wave.intensity;285286ctx.beginPath();287ctx.strokeStyle = `rgba(${textColor}, ${alpha})`;288ctx.lineWidth = 1;289ctx.arc(290wave.x * cellWidth,291wave.y * cellHeight,292radius,2930,2942 * Math.PI295);296ctx.stroke();297}298});299}300301animationFrameId.current = requestAnimationFrame(animate);302}, [backgroundColor, textColor, gridSize, animationSpeed, removeWaveLine]);303304useEffect(() => {305// Initialize background waves306const waves: Wave[] = [];307const numWaves = 4;308309for (let i = 0; i < numWaves; i++) {310waves.push({311x: gridSize * (0.25 + Math.random() * 0.5),312y: gridSize * (0.25 + Math.random() * 0.5),313frequency: 0.2 + Math.random() * 0.3,314amplitude: 0.5 + Math.random() * 0.5,315phase: Math.random() * Math.PI * 2,316speed: 0.5 + Math.random() * 0.5,317});318}319320wavesRef.current = waves;321322const canvas = canvasRef.current;323if (!canvas) return;324325// Initial resize326resizeCanvas();327328const handleResize = () => {329resizeCanvas();330};331332window.addEventListener('resize', handleResize);333canvas.addEventListener('mousemove', handleMouseMove);334canvas.addEventListener('mousedown', handleMouseDown);335canvas.addEventListener('mouseup', handleMouseUp);336337// Start animation338animate();339340return () => {341window.removeEventListener('resize', handleResize);342canvas.removeEventListener('mousemove', handleMouseMove);343canvas.removeEventListener('mousedown', handleMouseDown);344canvas.removeEventListener('mouseup', handleMouseUp);345346if (animationFrameId.current) {347cancelAnimationFrame(animationFrameId.current);348animationFrameId.current = null;349}350timeRef.current = 0;351clickWaves.current = [];352wavesRef.current = [];353};354}, [355animate,356resizeCanvas,357handleMouseMove,358handleMouseDown,359handleMouseUp,360gridSize,361]);362363return (364<div365ref={containerRef}366className='w-[40rem] h-full absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] overflow-hidden'367style={{ backgroundColor }}368>369<canvas ref={canvasRef} className='block w-full h-full' />370</div>371);372};373374export default DelicateAsciiDots;375
Prop | Type | Default | Description |
---|---|---|---|
backgroundColor | string | '#000000' | Background color of the canvas. |
textColor | string | '85, 85, 85' | RGB color value for the ASCII text dots. |
gridSize | number | 80 | Size of the grid for ASCII dot placement. |
removeWaveLine | boolean | true | Whether to remove the animated wave line (if true, the wave is not shown). |
animationSpeed | number | 0.75 | Speed of the ASCII dot animation. |