Bubble Cursor Effect
An interactive React component that creates beautiful animated bubbles that follow cursor movement with smooth physics-based animations.
An interactive React component that creates beautiful animated bubbles that follow cursor movement with smooth physics-based animations.
1'use client';2import React, { useEffect, useRef } from 'react';34interface BubbleCursorProps {5wrapperElement?: HTMLElement;6zIndex?: number;7}89class Particle {10lifeSpan: number;11initialLifeSpan: number;12velocity: { x: number; y: number };13position: { x: number; y: number };14baseDimension: number;1516constructor(x: number, y: number) {17this.initialLifeSpan = Math.floor(Math.random() * 60 + 60);18this.lifeSpan = this.initialLifeSpan;19this.velocity = {20x: (Math.random() < 0.5 ? -1 : 1) * (Math.random() / 10),21y: -0.4 + Math.random() * -1,22};23this.position = { x, y };24this.baseDimension = 4;25}2627update(context: CanvasRenderingContext2D) {28this.position.x += this.velocity.x;29this.position.y += this.velocity.y;30this.velocity.x += ((Math.random() < 0.5 ? -1 : 1) * 2) / 75;31this.velocity.y -= Math.random() / 600;32this.lifeSpan--;3334const scale =350.2 + (this.initialLifeSpan - this.lifeSpan) / this.initialLifeSpan;3637context.fillStyle = '#e6f1f7';38context.strokeStyle = '#3a92c5';39context.beginPath();40context.arc(41this.position.x - (this.baseDimension / 2) * scale,42this.position.y - this.baseDimension / 2,43this.baseDimension * scale,440,452 * Math.PI46);4748context.stroke();49context.fill();50context.closePath();51}52}53const BubbleCursor: React.FC<BubbleCursorProps> = ({ wrapperElement, zIndex }) => {54const canvasRef = useRef<HTMLCanvasElement | null>(null);55const particlesRef = useRef<Particle[]>([]);56const cursorRef = useRef({ x: 0, y: 0 });57const animationFrameRef = useRef<number | null>(null);5859useEffect(() => {60const prefersReducedMotion = window.matchMedia(61'(prefers-reduced-motion: reduce)'62);63let canvas: HTMLCanvasElement | null = null;64let context: CanvasRenderingContext2D | null = null;65let width = window.innerWidth;66let height = window.innerHeight;6768const init = () => {69if (prefersReducedMotion.matches) {70console.log(71'This browser has prefers reduced motion turned on, so the cursor did not init'72);73return false;74}7576canvas = canvasRef.current;77if (!canvas) return;7879context = canvas.getContext('2d');80if (!context) return;8182canvas.style.top = '0px';83canvas.style.left = '0px';84canvas.style.pointerEvents = 'none';85canvas.style.zIndex = zIndex ? zIndex.toString() : '';8687if (wrapperElement) {88canvas.style.position = 'absolute';89wrapperElement.appendChild(canvas);90canvas.width = wrapperElement.clientWidth;91canvas.height = wrapperElement.clientHeight;92} else {93canvas.style.position = 'fixed';94document.body.appendChild(canvas);95canvas.width = width;96canvas.height = height;97}9899bindEvents();100loop();101};102103const bindEvents = () => {104const element = wrapperElement || document.body;105element.addEventListener('mousemove', onMouseMove);106element.addEventListener('touchmove', onTouchMove, { passive: true });107element.addEventListener('touchstart', onTouchMove, { passive: true });108window.addEventListener('resize', onWindowResize);109};110111const onWindowResize = () => {112width = window.innerWidth;113height = window.innerHeight;114115if (!canvasRef.current) return;116117if (wrapperElement) {118canvasRef.current.width = wrapperElement.clientWidth;119canvasRef.current.height = wrapperElement.clientHeight;120} else {121canvasRef.current.width = width;122canvasRef.current.height = height;123}124};125126const onTouchMove = (e: TouchEvent) => {127if (e.touches.length > 0) {128for (let i = 0; i < e.touches.length; i++) {129addParticle(e.touches[i].clientX, e.touches[i].clientY);130}131}132};133134const onMouseMove = (e: MouseEvent) => {135if (wrapperElement) {136const boundingRect = wrapperElement.getBoundingClientRect();137cursorRef.current.x = e.clientX - boundingRect.left;138cursorRef.current.y = e.clientY - boundingRect.top;139} else {140cursorRef.current.x = e.clientX;141cursorRef.current.y = e.clientY;142}143144addParticle(cursorRef.current.x, cursorRef.current.y);145};146147const addParticle = (x: number, y: number) => {148particlesRef.current.push(new Particle(x, y));149};150151const updateParticles = () => {152if (!canvas || !context) return;153154if (particlesRef.current.length === 0) {155return;156}157158context.clearRect(0, 0, canvas.width, canvas.height);159160// Update161for (let i = 0; i < particlesRef.current.length; i++) {162particlesRef.current[i].update(context);163}164165// Remove dead particles166for (let i = particlesRef.current.length - 1; i >= 0; i--) {167if (particlesRef.current[i].lifeSpan < 0) {168particlesRef.current.splice(i, 1);169}170}171172if (particlesRef.current.length === 0) {173context.clearRect(0, 0, canvas.width, canvas.height);174}175};176177const loop = () => {178updateParticles();179animationFrameRef.current = requestAnimationFrame(loop);180};181182init();183184return () => {185if (canvas) {186canvas.remove();187}188if (animationFrameRef.current) {189cancelAnimationFrame(animationFrameRef.current);190}191const element = wrapperElement || document.body;192element.removeEventListener('mousemove', onMouseMove);193element.removeEventListener('touchmove', onTouchMove);194element.removeEventListener('touchstart', onTouchMove);195window.removeEventListener('resize', onWindowResize);196};197}, [wrapperElement]);198199return <canvas ref={canvasRef} />;200};201202export default BubbleCursor;203