Docs
Springy Cursor Effect
An interactive React component that adds a dynamic bubble effect, visually tracking cursor movement in real time.
Installation
Install dependencies
npm install framer-motion lucide-react
Run the following command
It will create a new file springy-cursor-effect.tsx
inside the components/mage-ui/cursor-effects
directory.
mkdir -p components/mage-ui/cursor-effects && touch components/mage-ui/cursor-effects/springy-cursor-effect.tsx
Paste the code
Open the newly created file and paste the following code:
'use client';
import React, { useEffect, useRef } from 'react';
interface SpringyCursorProps {
emoji?: string;
wrapperElement?: HTMLElement;
}
const SpringyCursor: React.FC<SpringyCursorProps> = ({
emoji = 'âš½',
wrapperElement,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const particlesRef = useRef<any[]>([]);
const cursorRef = useRef({ x: 0, y: 0 });
const animationFrameRef = useRef<number | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const nDots = 7;
const DELTAT = 0.01;
const SEGLEN = 10;
const SPRINGK = 10;
const MASS = 1;
const GRAVITY = 50;
const RESISTANCE = 10;
const STOPVEL = 0.1;
const STOPACC = 0.1;
const DOTSIZE = 11;
const BOUNCE = 0.7;
useEffect(() => {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
);
let canvas: HTMLCanvasElement | null = null;
let context: CanvasRenderingContext2D | null = null;
const init = () => {
if (prefersReducedMotion.matches) {
console.log(
'This browser has prefers reduced motion turned on, so the cursor did not init'
);
return false;
}
canvas = canvasRef.current;
if (!canvas) return;
context = canvas.getContext('2d');
if (!context) return;
canvas.style.top = '0px';
canvas.style.left = '0px';
canvas.style.pointerEvents = 'none';
if (wrapperElement) {
canvas.style.position = 'absolute';
wrapperElement.appendChild(canvas);
canvas.width = wrapperElement.clientWidth;
canvas.height = wrapperElement.clientHeight;
} else {
canvas.style.position = 'fixed';
document.body.appendChild(canvas);
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
// Save emoji as an image for performance
context.font = '16px serif';
context.textBaseline = 'middle';
context.textAlign = 'center';
const measurements = context.measureText(emoji);
const bgCanvas = document.createElement('canvas');
const bgContext = bgCanvas.getContext('2d');
if (bgContext) {
bgCanvas.width = measurements.width;
bgCanvas.height = measurements.actualBoundingBoxAscent * 2;
bgContext.textAlign = 'center';
bgContext.font = '16px serif';
bgContext.textBaseline = 'middle';
bgContext.fillText(
emoji,
bgCanvas.width / 2,
measurements.actualBoundingBoxAscent
);
for (let i = 0; i < nDots; i++) {
particlesRef.current[i] = new Particle(bgCanvas);
}
}
bindEvents();
loop();
};
const bindEvents = () => {
const element = wrapperElement || document.body;
element.addEventListener('mousemove', onMouseMove);
element.addEventListener('touchmove', onTouchMove, { passive: true });
element.addEventListener('touchstart', onTouchMove, { passive: true });
window.addEventListener('resize', onWindowResize);
};
const onWindowResize = () => {
if (!canvasRef.current) return;
if (wrapperElement) {
canvasRef.current.width = wrapperElement.clientWidth;
canvasRef.current.height = wrapperElement.clientHeight;
} else {
canvasRef.current.width = window.innerWidth;
canvasRef.current.height = window.innerHeight;
}
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length > 0) {
if (wrapperElement) {
const boundingRect = wrapperElement.getBoundingClientRect();
cursorRef.current.x = e.touches[0].clientX - boundingRect.left;
cursorRef.current.y = e.touches[0].clientY - boundingRect.top;
} else {
cursorRef.current.x = e.touches[0].clientX;
cursorRef.current.y = e.touches[0].clientY;
}
}
};
const onMouseMove = (e: MouseEvent) => {
if (wrapperElement) {
const boundingRect = wrapperElement.getBoundingClientRect();
cursorRef.current.x = e.clientX - boundingRect.left;
cursorRef.current.y = e.clientY - boundingRect.top;
} else {
cursorRef.current.x = e.clientX;
cursorRef.current.y = e.clientY;
}
};
const updateParticles = () => {
if (!canvasRef.current || !context) return;
canvasRef.current.width = canvasRef.current.width;
// follow mouse
particlesRef.current[0].position.x = cursorRef.current.x;
particlesRef.current[0].position.y = cursorRef.current.y;
// Start from 2nd dot
for (let i = 1; i < nDots; i++) {
let spring = new Vec(0, 0);
if (i > 0) {
springForce(i - 1, i, spring);
}
if (i < nDots - 1) {
springForce(i + 1, i, spring);
}
let resist = new Vec(
-particlesRef.current[i].velocity.x * RESISTANCE,
-particlesRef.current[i].velocity.y * RESISTANCE
);
let accel = new Vec(
(spring.X + resist.X) / MASS,
(spring.Y + resist.Y) / MASS + GRAVITY
);
particlesRef.current[i].velocity.x += DELTAT * accel.X;
particlesRef.current[i].velocity.y += DELTAT * accel.Y;
if (
Math.abs(particlesRef.current[i].velocity.x) < STOPVEL &&
Math.abs(particlesRef.current[i].velocity.y) < STOPVEL &&
Math.abs(accel.X) < STOPACC &&
Math.abs(accel.Y) < STOPACC
) {
particlesRef.current[i].velocity.x = 0;
particlesRef.current[i].velocity.y = 0;
}
particlesRef.current[i].position.x +=
particlesRef.current[i].velocity.x;
particlesRef.current[i].position.y +=
particlesRef.current[i].velocity.y;
let height = canvasRef.current.clientHeight;
let width = canvasRef.current.clientWidth;
if (particlesRef.current[i].position.y >= height - DOTSIZE - 1) {
if (particlesRef.current[i].velocity.y > 0) {
particlesRef.current[i].velocity.y =
BOUNCE * -particlesRef.current[i].velocity.y;
}
particlesRef.current[i].position.y = height - DOTSIZE - 1;
}
if (particlesRef.current[i].position.x >= width - DOTSIZE) {
if (particlesRef.current[i].velocity.x > 0) {
particlesRef.current[i].velocity.x =
BOUNCE * -particlesRef.current[i].velocity.x;
}
particlesRef.current[i].position.x = width - DOTSIZE - 1;
}
if (particlesRef.current[i].position.x < 0) {
if (particlesRef.current[i].velocity.x < 0) {
particlesRef.current[i].velocity.x =
BOUNCE * -particlesRef.current[i].velocity.x;
}
particlesRef.current[i].position.x = 0;
}
particlesRef.current[i].draw(context);
}
};
const loop = () => {
updateParticles();
animationFrameRef.current = requestAnimationFrame(loop);
};
class Vec {
X: number;
Y: number;
constructor(X: number, Y: number) {
this.X = X;
this.Y = Y;
}
}
function springForce(i: number, j: number, spring: Vec) {
let dx =
particlesRef.current[i].position.x - particlesRef.current[j].position.x;
let dy =
particlesRef.current[i].position.y - particlesRef.current[j].position.y;
let len = Math.sqrt(dx * dx + dy * dy);
if (len > SEGLEN) {
let springF = SPRINGK * (len - SEGLEN);
spring.X += (dx / len) * springF;
spring.Y += (dy / len) * springF;
}
}
class Particle {
position: { x: number; y: number };
velocity: { x: number; y: number };
canv: HTMLCanvasElement;
constructor(canvasItem: HTMLCanvasElement) {
this.position = { x: cursorRef.current.x, y: cursorRef.current.y };
this.velocity = { x: 0, y: 0 };
this.canv = canvasItem;
}
draw(context: CanvasRenderingContext2D) {
context.drawImage(
this.canv,
this.position.x - this.canv.width / 2,
this.position.y - this.canv.height / 2,
this.canv.width,
this.canv.height
);
}
}
init();
return () => {
if (canvas) {
canvas.remove();
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
const element = wrapperElement || document.body;
element.removeEventListener('mousemove', onMouseMove);
element.removeEventListener('touchmove', onTouchMove);
element.removeEventListener('touchstart', onTouchMove);
window.removeEventListener('resize', onWindowResize);
};
}, [emoji, wrapperElement]);
return (
<div ref={wrapperRef} className="h-screen">
<canvas ref={canvasRef} />
</div>
);
};
export default SpringyCursor;