Bubble Cursor 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';2import React, { useEffect, useRef } from 'react';34interface BubbleCursorProps {5wrapperElement?: HTMLElement;6}78class Particle {9lifeSpan: number;10initialLifeSpan: number;11velocity: { x: number; y: number };12position: { x: number; y: number };13baseDimension: number;1415constructor(x: number, y: number) {16this.initialLifeSpan = Math.floor(Math.random() * 60 + 60);17this.lifeSpan = this.initialLifeSpan;18this.velocity = {19x: (Math.random() < 0.5 ? -1 : 1) * (Math.random() / 10),20y: -0.4 + Math.random() * -1,21};22this.position = { x, y };23this.baseDimension = 4;24}2526update(context: CanvasRenderingContext2D) {27this.position.x += this.velocity.x;28this.position.y += this.velocity.y;29this.velocity.x += ((Math.random() < 0.5 ? -1 : 1) * 2) / 75;30this.velocity.y -= Math.random() / 600;31this.lifeSpan--;3233const scale =340.2 + (this.initialLifeSpan - this.lifeSpan) / this.initialLifeSpan;3536context.fillStyle = '#e6f1f7';37context.strokeStyle = '#3a92c5';38context.beginPath();39context.arc(40this.position.x - (this.baseDimension / 2) * scale,41this.position.y - this.baseDimension / 2,42this.baseDimension * scale,430,442 * Math.PI45);4647context.stroke();48context.fill();49context.closePath();50}51}52const BubbleCursor: React.FC<BubbleCursorProps> = ({ wrapperElement }) => {53const canvasRef = useRef<HTMLCanvasElement | null>(null);54const particlesRef = useRef<Particle[]>([]);55const cursorRef = useRef({ x: 0, y: 0 });56const animationFrameRef = useRef<number | null>(null);5758useEffect(() => {59const prefersReducedMotion = window.matchMedia(60'(prefers-reduced-motion: reduce)'61);62let canvas: HTMLCanvasElement | null = null;63let context: CanvasRenderingContext2D | null = null;64let width = window.innerWidth;65let height = window.innerHeight;6667const init = () => {68if (prefersReducedMotion.matches) {69console.log(70'This browser has prefers reduced motion turned on, so the cursor did not init'71);72return false;73}7475canvas = canvasRef.current;76if (!canvas) return;7778context = canvas.getContext('2d');79if (!context) return;8081canvas.style.top = '0px';82canvas.style.left = '0px';83canvas.style.pointerEvents = 'none';8485if (wrapperElement) {86canvas.style.position = 'absolute';87wrapperElement.appendChild(canvas);88canvas.width = wrapperElement.clientWidth;89canvas.height = wrapperElement.clientHeight;90} else {91canvas.style.position = 'fixed';92document.body.appendChild(canvas);93canvas.width = width;94canvas.height = height;95}9697bindEvents();98loop();99};100101const bindEvents = () => {102const element = wrapperElement || document.body;103element.addEventListener('mousemove', onMouseMove);104element.addEventListener('touchmove', onTouchMove, { passive: true });105element.addEventListener('touchstart', onTouchMove, { passive: true });106window.addEventListener('resize', onWindowResize);107};108109const onWindowResize = () => {110width = window.innerWidth;111height = window.innerHeight;112113if (!canvasRef.current) return;114115if (wrapperElement) {116canvasRef.current.width = wrapperElement.clientWidth;117canvasRef.current.height = wrapperElement.clientHeight;118} else {119canvasRef.current.width = width;120canvasRef.current.height = height;121}122};123124const onTouchMove = (e: TouchEvent) => {125if (e.touches.length > 0) {126for (let i = 0; i < e.touches.length; i++) {127addParticle(e.touches[i].clientX, e.touches[i].clientY);128}129}130};131132const onMouseMove = (e: MouseEvent) => {133if (wrapperElement) {134const boundingRect = wrapperElement.getBoundingClientRect();135cursorRef.current.x = e.clientX - boundingRect.left;136cursorRef.current.y = e.clientY - boundingRect.top;137} else {138cursorRef.current.x = e.clientX;139cursorRef.current.y = e.clientY;140}141142addParticle(cursorRef.current.x, cursorRef.current.y);143};144145const addParticle = (x: number, y: number) => {146particlesRef.current.push(new Particle(x, y));147};148149const updateParticles = () => {150if (!canvas || !context) return;151152if (particlesRef.current.length === 0) {153return;154}155156context.clearRect(0, 0, canvas.width, canvas.height);157158// Update159for (let i = 0; i < particlesRef.current.length; i++) {160particlesRef.current[i].update(context);161}162163// Remove dead particles164for (let i = particlesRef.current.length - 1; i >= 0; i--) {165if (particlesRef.current[i].lifeSpan < 0) {166particlesRef.current.splice(i, 1);167}168}169170if (particlesRef.current.length === 0) {171context.clearRect(0, 0, canvas.width, canvas.height);172}173};174175const loop = () => {176updateParticles();177animationFrameRef.current = requestAnimationFrame(loop);178};179180init();181182return () => {183if (canvas) {184canvas.remove();185}186if (animationFrameRef.current) {187cancelAnimationFrame(animationFrameRef.current);188}189const element = wrapperElement || document.body;190element.removeEventListener('mousemove', onMouseMove);191element.removeEventListener('touchmove', onTouchMove);192element.removeEventListener('touchstart', onTouchMove);193window.removeEventListener('resize', onWindowResize);194};195}, [wrapperElement]);196197return <canvas ref={canvasRef} />;198};199200export default BubbleCursor;201