Springy 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';23import React, { useEffect, useRef } from 'react';45interface SpringyCursorProps {6emoji?: string;7wrapperElement?: HTMLElement;8}910const SpringyCursor: React.FC<SpringyCursorProps> = ({11emoji = '⚽',12wrapperElement,13}) => {14const canvasRef = useRef<HTMLCanvasElement | null>(null);15const particlesRef = useRef<any[]>([]);16const cursorRef = useRef({ x: 0, y: 0 });17const animationFrameRef = useRef<number | null>(null);1819const nDots = 7;20const DELTAT = 0.01;21const SEGLEN = 10;22const SPRINGK = 10;23const MASS = 1;24const GRAVITY = 50;25const RESISTANCE = 10;26const STOPVEL = 0.1;27const STOPACC = 0.1;28const DOTSIZE = 11;29const BOUNCE = 0.7;3031useEffect(() => {32const prefersReducedMotion = window.matchMedia(33'(prefers-reduced-motion: reduce)'34);35let canvas: HTMLCanvasElement | null = null;36let context: CanvasRenderingContext2D | null = null;3738const init = () => {39if (prefersReducedMotion.matches) {40console.log(41'This browser has prefers reduced motion turned on, so the cursor did not init'42);43return false;44}4546canvas = canvasRef.current;47if (!canvas) return;4849context = canvas.getContext('2d');50if (!context) return;5152canvas.style.top = '0px';53canvas.style.left = '0px';54canvas.style.pointerEvents = 'none';5556if (wrapperElement) {57canvas.style.position = 'absolute';58wrapperElement.appendChild(canvas);59canvas.width = wrapperElement.clientWidth;60canvas.height = wrapperElement.clientHeight;61} else {62canvas.style.position = 'fixed';63document.body.appendChild(canvas);64canvas.width = window.innerWidth;65canvas.height = window.innerHeight;66}6768// Save emoji as an image for performance69context.font = '16px serif';70context.textBaseline = 'middle';71context.textAlign = 'center';7273const measurements = context.measureText(emoji);74const bgCanvas = document.createElement('canvas');75const bgContext = bgCanvas.getContext('2d');7677if (bgContext) {78bgCanvas.width = measurements.width;79bgCanvas.height = measurements.actualBoundingBoxAscent * 2;8081bgContext.textAlign = 'center';82bgContext.font = '16px serif';83bgContext.textBaseline = 'middle';84bgContext.fillText(85emoji,86bgCanvas.width / 2,87measurements.actualBoundingBoxAscent88);8990for (let i = 0; i < nDots; i++) {91particlesRef.current[i] = new Particle(bgCanvas);92}93}9495bindEvents();96loop();97};9899const bindEvents = () => {100const element = wrapperElement || document.body;101element.addEventListener('mousemove', onMouseMove);102element.addEventListener('touchmove', onTouchMove, { passive: true });103element.addEventListener('touchstart', onTouchMove, { passive: true });104window.addEventListener('resize', onWindowResize);105};106107const onWindowResize = () => {108if (!canvasRef.current) return;109110if (wrapperElement) {111canvasRef.current.width = wrapperElement.clientWidth;112canvasRef.current.height = wrapperElement.clientHeight;113} else {114canvasRef.current.width = window.innerWidth;115canvasRef.current.height = window.innerHeight;116}117};118119const onTouchMove = (e: TouchEvent) => {120if (e.touches.length > 0) {121if (wrapperElement) {122const boundingRect = wrapperElement.getBoundingClientRect();123cursorRef.current.x = e.touches[0].clientX - boundingRect.left;124cursorRef.current.y = e.touches[0].clientY - boundingRect.top;125} else {126cursorRef.current.x = e.touches[0].clientX;127cursorRef.current.y = e.touches[0].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}141};142143const updateParticles = () => {144if (!canvasRef.current || !context) return;145146canvasRef.current.width = canvasRef.current.width;147148// follow mouse149particlesRef.current[0].position.x = cursorRef.current.x;150particlesRef.current[0].position.y = cursorRef.current.y;151152// Start from 2nd dot153for (let i = 1; i < nDots; i++) {154let spring = new Vec(0, 0);155156if (i > 0) {157springForce(i - 1, i, spring);158}159160if (i < nDots - 1) {161springForce(i + 1, i, spring);162}163164let resist = new Vec(165-particlesRef.current[i].velocity.x * RESISTANCE,166-particlesRef.current[i].velocity.y * RESISTANCE167);168169let accel = new Vec(170(spring.X + resist.X) / MASS,171(spring.Y + resist.Y) / MASS + GRAVITY172);173174particlesRef.current[i].velocity.x += DELTAT * accel.X;175particlesRef.current[i].velocity.y += DELTAT * accel.Y;176177if (178Math.abs(particlesRef.current[i].velocity.x) < STOPVEL &&179Math.abs(particlesRef.current[i].velocity.y) < STOPVEL &&180Math.abs(accel.X) < STOPACC &&181Math.abs(accel.Y) < STOPACC182) {183particlesRef.current[i].velocity.x = 0;184particlesRef.current[i].velocity.y = 0;185}186187particlesRef.current[i].position.x +=188particlesRef.current[i].velocity.x;189particlesRef.current[i].position.y +=190particlesRef.current[i].velocity.y;191192let height = canvasRef.current.clientHeight;193let width = canvasRef.current.clientWidth;194195if (particlesRef.current[i].position.y >= height - DOTSIZE - 1) {196if (particlesRef.current[i].velocity.y > 0) {197particlesRef.current[i].velocity.y =198BOUNCE * -particlesRef.current[i].velocity.y;199}200particlesRef.current[i].position.y = height - DOTSIZE - 1;201}202203if (particlesRef.current[i].position.x >= width - DOTSIZE) {204if (particlesRef.current[i].velocity.x > 0) {205particlesRef.current[i].velocity.x =206BOUNCE * -particlesRef.current[i].velocity.x;207}208particlesRef.current[i].position.x = width - DOTSIZE - 1;209}210211if (particlesRef.current[i].position.x < 0) {212if (particlesRef.current[i].velocity.x < 0) {213particlesRef.current[i].velocity.x =214BOUNCE * -particlesRef.current[i].velocity.x;215}216particlesRef.current[i].position.x = 0;217}218219particlesRef.current[i].draw(context);220}221};222223const loop = () => {224updateParticles();225animationFrameRef.current = requestAnimationFrame(loop);226};227228class Vec {229X: number;230Y: number;231232constructor(X: number, Y: number) {233this.X = X;234this.Y = Y;235}236}237238function springForce(i: number, j: number, spring: Vec) {239let dx =240particlesRef.current[i].position.x - particlesRef.current[j].position.x;241let dy =242particlesRef.current[i].position.y - particlesRef.current[j].position.y;243let len = Math.sqrt(dx * dx + dy * dy);244if (len > SEGLEN) {245let springF = SPRINGK * (len - SEGLEN);246spring.X += (dx / len) * springF;247spring.Y += (dy / len) * springF;248}249}250251class Particle {252position: { x: number; y: number };253velocity: { x: number; y: number };254canv: HTMLCanvasElement;255256constructor(canvasItem: HTMLCanvasElement) {257this.position = { x: cursorRef.current.x, y: cursorRef.current.y };258this.velocity = { x: 0, y: 0 };259this.canv = canvasItem;260}261262draw(context: CanvasRenderingContext2D) {263context.drawImage(264this.canv,265this.position.x - this.canv.width / 2,266this.position.y - this.canv.height / 2,267this.canv.width,268this.canv.height269);270}271}272273init();274275return () => {276if (canvas) {277canvas.remove();278}279if (animationFrameRef.current) {280cancelAnimationFrame(animationFrameRef.current);281}282const element = wrapperElement || document.body;283element.removeEventListener('mousemove', onMouseMove);284element.removeEventListener('touchmove', onTouchMove);285element.removeEventListener('touchstart', onTouchMove);286window.removeEventListener('resize', onWindowResize);287};288}, [emoji, wrapperElement]);289290return <canvas ref={canvasRef} />;291};292293export default SpringyCursor;294
Prop | Type | Default | Description |
---|---|---|---|
emoji | string | ⚽ | you can change the emoji to your need |
wrapperElement | HTMLElement |