Interactive Dots
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 InteractiveDotsProps {6backgroundColor?: string;7dotColor?: string;8gridSpacing?: number;9animationSpeed?: number;10removeWaveLine?: boolean;11}1213const InteractiveDots = ({14backgroundColor = '#F0EEE6',15dotColor = '#666666',16gridSpacing = 30,17animationSpeed = 0.005,18removeWaveLine = true19}: InteractiveDotsProps) => {20const canvasRef = useRef<HTMLCanvasElement>(null);21const timeRef = useRef<number>(0);22const animationFrameId = useRef<number | null>(null);23const mouseRef = useRef({ x: 0, y: 0, isDown: false });24const ripples = useRef<25Array<{ x: number; y: number; time: number; intensity: number }>26>([]);27const dotsRef = useRef<28Array<{29x: number;30y: number;31originalX: number;32originalY: number;33phase: number;34}>35>([]);36const dprRef = useRef<number>(1);3738const getMouseInfluence = (x: number, y: number): number => {39const dx = x - mouseRef.current.x;40const dy = y - mouseRef.current.y;41const distance = Math.sqrt(dx * dx + dy * dy);42const maxDistance = 150;43return Math.max(0, 1 - distance / maxDistance);44};4546const getRippleInfluence = (47x: number,48y: number,49currentTime: number50): number => {51let totalInfluence = 0;52ripples.current.forEach((ripple) => {53const age = currentTime - ripple.time;54const maxAge = 3000;55if (age < maxAge) {56const dx = x - ripple.x;57const dy = y - ripple.y;58const distance = Math.sqrt(dx * dx + dy * dy);59const rippleRadius = (age / maxAge) * 300;60const rippleWidth = 60;61if (Math.abs(distance - rippleRadius) < rippleWidth) {62const rippleStrength = (1 - age / maxAge) * ripple.intensity;63const proximityToRipple =641 - Math.abs(distance - rippleRadius) / rippleWidth;65totalInfluence += rippleStrength * proximityToRipple;66}67}68});69return Math.min(totalInfluence, 2);70};7172const initializeDots = useCallback(() => {73const canvas = canvasRef.current;74if (!canvas) return;7576// Use CSS pixel dimensions for calculations77const canvasWidth = canvas.clientWidth;78const canvasHeight = canvas.clientHeight;7980const dots: Array<{81x: number;82y: number;83originalX: number;84originalY: number;85phase: number;86}> = [];8788// Create grid of dots89for (let x = gridSpacing / 2; x < canvasWidth; x += gridSpacing) {90for (let y = gridSpacing / 2; y < canvasHeight; y += gridSpacing) {91dots.push({92x,93y,94originalX: x,95originalY: y,96phase: Math.random() * Math.PI * 2, // Random phase for subtle animation97});98}99}100101dotsRef.current = dots;102}, [gridSpacing]);103104const resizeCanvas = useCallback(() => {105const canvas = canvasRef.current;106if (!canvas) return;107108const dpr = window.devicePixelRatio || 1;109dprRef.current = dpr;110111const displayWidth = window.innerWidth;112const displayHeight = window.innerHeight;113114// Set the actual size in memory (scaled up for high DPI)115canvas.width = displayWidth * dpr;116canvas.height = displayHeight * dpr;117118// Scale the canvas back down using CSS119canvas.style.width = displayWidth + 'px';120canvas.style.height = displayHeight + 'px';121122// Scale the drawing context so everything draws at the correct size123const ctx = canvas.getContext('2d');124if (ctx) {125ctx.scale(dpr, dpr);126}127128initializeDots();129}, [initializeDots]);130131const handleMouseMove = useCallback((e: MouseEvent) => {132const canvas = canvasRef.current;133if (!canvas) return;134135const rect = canvas.getBoundingClientRect();136mouseRef.current.x = e.clientX - rect.left;137mouseRef.current.y = e.clientY - rect.top;138}, []);139140const handleMouseDown = useCallback((e: MouseEvent) => {141mouseRef.current.isDown = true;142const canvas = canvasRef.current;143if (!canvas) return;144145const rect = canvas.getBoundingClientRect();146const x = e.clientX - rect.left;147const y = e.clientY - rect.top;148149ripples.current.push({150x,151y,152time: Date.now(),153intensity: 2,154});155156const now = Date.now();157ripples.current = ripples.current.filter(158(ripple) => now - ripple.time < 3000159);160}, []);161162const handleMouseUp = useCallback(() => {163mouseRef.current.isDown = false;164}, []);165166const animate = useCallback(() => {167const canvas = canvasRef.current;168if (!canvas) return;169170const ctx = canvas.getContext('2d');171if (!ctx) return;172173timeRef.current += animationSpeed;174const currentTime = Date.now();175176// Use CSS pixel dimensions for calculations177const canvasWidth = canvas.clientWidth;178const canvasHeight = canvas.clientHeight;179180// Clear canvas181ctx.fillStyle = backgroundColor;182ctx.fillRect(0, 0, canvasWidth, canvasHeight);183184// Update and draw dots185dotsRef.current.forEach((dot) => {186const mouseInfluence = getMouseInfluence(dot.originalX, dot.originalY);187const rippleInfluence = getRippleInfluence(188dot.originalX,189dot.originalY,190currentTime191);192const totalInfluence = mouseInfluence + rippleInfluence;193194// Keep dots at original positions - no movement195dot.x = dot.originalX;196dot.y = dot.originalY;197198// Calculate dot properties based on influences - only scaling199const baseDotSize = 2;200const dotSize =201baseDotSize +202totalInfluence * 6 +203Math.sin(timeRef.current + dot.phase) * 0.5;204const opacity = Math.max(2050.3,2060.6 +207totalInfluence * 0.4 +208Math.abs(Math.sin(timeRef.current * 0.5 + dot.phase)) * 0.1209);210211// Draw dot212ctx.beginPath();213ctx.arc(dot.x, dot.y, dotSize, 0, Math.PI * 2);214215// Color with opacity216const red = Number.parseInt(dotColor.slice(1, 3), 16);217const green = Number.parseInt(dotColor.slice(3, 5), 16);218const blue = Number.parseInt(dotColor.slice(5, 7), 16);219ctx.fillStyle = `rgba(${red}, ${green}, ${blue}, ${opacity})`;220ctx.fill();221});222223// Draw ripple effects224if (!removeWaveLine) {225ripples.current.forEach((ripple) => {226const age = currentTime - ripple.time;227const maxAge = 3000;228if (age < maxAge) {229const progress = age / maxAge;230const radius = progress * 300;231const alpha = (1 - progress) * 0.3 * ripple.intensity;232233// Outer ripple234ctx.beginPath();235ctx.strokeStyle = `rgba(100, 100, 100, ${alpha})`;236ctx.lineWidth = 2;237ctx.arc(ripple.x, ripple.y, radius, 0, 2 * Math.PI);238ctx.stroke();239240// Inner ripple241const innerRadius = progress * 150;242const innerAlpha = (1 - progress) * 0.2 * ripple.intensity;243ctx.beginPath();244ctx.strokeStyle = `rgba(120, 120, 120, ${innerAlpha})`;245ctx.lineWidth = 1;246ctx.arc(ripple.x, ripple.y, innerRadius, 0, 2 * Math.PI);247ctx.stroke();248}249});250}251252animationFrameId.current = requestAnimationFrame(animate);253}, [backgroundColor, dotColor, removeWaveLine, animationSpeed]);254255useEffect(() => {256const canvas = canvasRef.current;257if (!canvas) return;258259resizeCanvas();260261const handleResize = () => resizeCanvas();262263window.addEventListener('resize', handleResize);264canvas.addEventListener('mousemove', handleMouseMove);265canvas.addEventListener('mousedown', handleMouseDown);266canvas.addEventListener('mouseup', handleMouseUp);267268animate();269270return () => {271window.removeEventListener('resize', handleResize);272canvas.removeEventListener('mousemove', handleMouseMove);273canvas.removeEventListener('mousedown', handleMouseDown);274canvas.removeEventListener('mouseup', handleMouseUp);275276if (animationFrameId.current) {277cancelAnimationFrame(animationFrameId.current);278animationFrameId.current = null;279}280timeRef.current = 0;281ripples.current = [];282dotsRef.current = [];283};284}, [animate, resizeCanvas, handleMouseMove, handleMouseDown, handleMouseUp]);285286return (287<div288className='absolute inset-0 w-full h-full overflow-hidden'289style={{ backgroundColor }}290>291<canvas ref={canvasRef} className='block w-full h-full' />292</div>293);294};295296export default InteractiveDots;297
Prop | Type | Default | Description |
---|---|---|---|
backgroundColor | string | '#F0EEE6' | Background color of the canvas. |
dotColor | string | '#666666' | Color of the dots. |
gridSpacing | number | 30 | Spacing between the dots in the grid. |
animationSpeed | number | 0.005 | Speed of the dot animation. |
removeWaveLine | boolean | true | Whether to remove the animated wave line (if true, the wave is not shown). |