Springy Cursor Effect
An interactive React component that generates a trail of emoji particles following the cursor with a spring-like motion, creating a smooth and dynamic elastic effect in real time.
An interactive React component that generates a trail of emoji particles following the cursor with a spring-like motion, creating a smooth and dynamic elastic effect in real time.
1'use client';23import React, { useEffect, useRef } from 'react';45interface SpringyCursorProps {6emoji?: string;7wrapperElement?: HTMLElement;8zIndex?: number;9}1011const SpringyCursor: React.FC<SpringyCursorProps> = ({12emoji = '⚽',13wrapperElement,14zIndex,15}) => {16const canvasRef = useRef<HTMLCanvasElement | null>(null);17const particlesRef = useRef<any[]>([]);18const cursorRef = useRef({ x: 0, y: 0 });19const animationFrameRef = useRef<number | null>(null);2021const nDots = 7;22const DELTAT = 0.01;23const SEGLEN = 10;24const SPRINGK = 10;25const MASS = 1;26const GRAVITY = 50;27const RESISTANCE = 10;28const STOPVEL = 0.1;29const STOPACC = 0.1;30const DOTSIZE = 11;31const BOUNCE = 0.7;3233useEffect(() => {34const prefersReducedMotion = window.matchMedia(35'(prefers-reduced-motion: reduce)'36);37let canvas: HTMLCanvasElement | null = null;38let context: CanvasRenderingContext2D | null = null;3940const init = () => {41if (prefersReducedMotion.matches) {42console.log(43'This browser has prefers reduced motion turned on, so the cursor did not init'44);45return false;46}4748canvas = canvasRef.current;49if (!canvas) return;5051context = canvas.getContext('2d');52if (!context) return;5354canvas.style.top = '0px';55canvas.style.left = '0px';56canvas.style.pointerEvents = 'none';57canvas.style.zIndex = zIndex ? zIndex.toString() : '';5859if (wrapperElement) {60canvas.style.position = 'absolute';61wrapperElement.appendChild(canvas);62canvas.width = wrapperElement.clientWidth;63canvas.height = wrapperElement.clientHeight;64} else {65canvas.style.position = 'fixed';66document.body.appendChild(canvas);67canvas.width = window.innerWidth;68canvas.height = window.innerHeight;69}7071// Save emoji as an image for performance72context.font = '16px serif';73context.textBaseline = 'middle';74context.textAlign = 'center';7576const measurements = context.measureText(emoji);77const bgCanvas = document.createElement('canvas');78const bgContext = bgCanvas.getContext('2d');7980if (bgContext) {81bgCanvas.width = measurements.width;82bgCanvas.height = measurements.actualBoundingBoxAscent * 2;8384bgContext.textAlign = 'center';85bgContext.font = '16px serif';86bgContext.textBaseline = 'middle';87bgContext.fillText(88emoji,89bgCanvas.width / 2,90measurements.actualBoundingBoxAscent91);9293for (let i = 0; i < nDots; i++) {94particlesRef.current[i] = new Particle(bgCanvas);95}96}9798bindEvents();99loop();100};101102const bindEvents = () => {103const element = wrapperElement || document.body;104element.addEventListener('mousemove', onMouseMove);105element.addEventListener('touchmove', onTouchMove, { passive: true });106element.addEventListener('touchstart', onTouchMove, { passive: true });107window.addEventListener('resize', onWindowResize);108};109110const onWindowResize = () => {111if (!canvasRef.current) return;112113if (wrapperElement) {114canvasRef.current.width = wrapperElement.clientWidth;115canvasRef.current.height = wrapperElement.clientHeight;116} else {117canvasRef.current.width = window.innerWidth;118canvasRef.current.height = window.innerHeight;119}120};121122const onTouchMove = (e: TouchEvent) => {123if (e.touches.length > 0) {124if (wrapperElement) {125const boundingRect = wrapperElement.getBoundingClientRect();126cursorRef.current.x = e.touches[0].clientX - boundingRect.left;127cursorRef.current.y = e.touches[0].clientY - boundingRect.top;128} else {129cursorRef.current.x = e.touches[0].clientX;130cursorRef.current.y = e.touches[0].clientY;131}132}133};134135const onMouseMove = (e: MouseEvent) => {136if (wrapperElement) {137const boundingRect = wrapperElement.getBoundingClientRect();138cursorRef.current.x = e.clientX - boundingRect.left;139cursorRef.current.y = e.clientY - boundingRect.top;140} else {141cursorRef.current.x = e.clientX;142cursorRef.current.y = e.clientY;143}144};145146const updateParticles = () => {147if (!canvasRef.current || !context) return;148149canvasRef.current.width = canvasRef.current.width;150151// follow mouse152particlesRef.current[0].position.x = cursorRef.current.x;153particlesRef.current[0].position.y = cursorRef.current.y;154155// Start from 2nd dot156for (let i = 1; i < nDots; i++) {157let spring = new Vec(0, 0);158159if (i > 0) {160springForce(i - 1, i, spring);161}162163if (i < nDots - 1) {164springForce(i + 1, i, spring);165}166167let resist = new Vec(168-particlesRef.current[i].velocity.x * RESISTANCE,169-particlesRef.current[i].velocity.y * RESISTANCE170);171172let accel = new Vec(173(spring.X + resist.X) / MASS,174(spring.Y + resist.Y) / MASS + GRAVITY175);176177particlesRef.current[i].velocity.x += DELTAT * accel.X;178particlesRef.current[i].velocity.y += DELTAT * accel.Y;179180if (181Math.abs(particlesRef.current[i].velocity.x) < STOPVEL &&182Math.abs(particlesRef.current[i].velocity.y) < STOPVEL &&183Math.abs(accel.X) < STOPACC &&184Math.abs(accel.Y) < STOPACC185) {186particlesRef.current[i].velocity.x = 0;187particlesRef.current[i].velocity.y = 0;188}189190particlesRef.current[i].position.x +=191particlesRef.current[i].velocity.x;192particlesRef.current[i].position.y +=193particlesRef.current[i].velocity.y;194195let height = canvasRef.current.clientHeight;196let width = canvasRef.current.clientWidth;197198if (particlesRef.current[i].position.y >= height - DOTSIZE - 1) {199if (particlesRef.current[i].velocity.y > 0) {200particlesRef.current[i].velocity.y =201BOUNCE * -particlesRef.current[i].velocity.y;202}203particlesRef.current[i].position.y = height - DOTSIZE - 1;204}205206if (particlesRef.current[i].position.x >= width - DOTSIZE) {207if (particlesRef.current[i].velocity.x > 0) {208particlesRef.current[i].velocity.x =209BOUNCE * -particlesRef.current[i].velocity.x;210}211particlesRef.current[i].position.x = width - DOTSIZE - 1;212}213214if (particlesRef.current[i].position.x < 0) {215if (particlesRef.current[i].velocity.x < 0) {216particlesRef.current[i].velocity.x =217BOUNCE * -particlesRef.current[i].velocity.x;218}219particlesRef.current[i].position.x = 0;220}221222particlesRef.current[i].draw(context);223}224};225226const loop = () => {227updateParticles();228animationFrameRef.current = requestAnimationFrame(loop);229};230231class Vec {232X: number;233Y: number;234235constructor(X: number, Y: number) {236this.X = X;237this.Y = Y;238}239}240241function springForce(i: number, j: number, spring: Vec) {242let dx =243particlesRef.current[i].position.x - particlesRef.current[j].position.x;244let dy =245particlesRef.current[i].position.y - particlesRef.current[j].position.y;246let len = Math.sqrt(dx * dx + dy * dy);247if (len > SEGLEN) {248let springF = SPRINGK * (len - SEGLEN);249spring.X += (dx / len) * springF;250spring.Y += (dy / len) * springF;251}252}253254class Particle {255position: { x: number; y: number };256velocity: { x: number; y: number };257canv: HTMLCanvasElement;258259constructor(canvasItem: HTMLCanvasElement) {260this.position = { x: cursorRef.current.x, y: cursorRef.current.y };261this.velocity = { x: 0, y: 0 };262this.canv = canvasItem;263}264265draw(context: CanvasRenderingContext2D) {266context.drawImage(267this.canv,268this.position.x - this.canv.width / 2,269this.position.y - this.canv.height / 2,270this.canv.width,271this.canv.height272);273}274}275276init();277278return () => {279if (canvas) {280canvas.remove();281}282if (animationFrameRef.current) {283cancelAnimationFrame(animationFrameRef.current);284}285const element = wrapperElement || document.body;286element.removeEventListener('mousemove', onMouseMove);287element.removeEventListener('touchmove', onTouchMove);288element.removeEventListener('touchstart', onTouchMove);289window.removeEventListener('resize', onWindowResize);290};291}, [emoji, wrapperElement]);292293return <canvas ref={canvasRef} />;294};295296export default SpringyCursor;297
| Prop | Type | Default | Description |
|---|---|---|---|
emoji | string | ⚽ | you can change the emoji to your need |
wrapperElement | HTMLElement |