Trail 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 { useEffect, useRef } from 'react';45interface TrailingCursorProps {6element?: HTMLElement;7particles?: number;8rate?: number;9baseImageSrc?: string;10}1112const TrailingCursor: React.FC<TrailingCursorProps> = ({13element,14particles = 15,15rate = 0.4,16baseImageSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAATCAYAAACk9eypAAAAAXNSR0IArs4c6QAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAhGVYSWZNTQAqAAAACAAFARIAAwAAAAEAAQAAARoABQAAAAEAAABKARsABQAAAAEAAABSASgAAwAAAAEAAgAAh2kABAAAAAEAAABaAAAAAAAAAEgAAAABAAAASAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAADKADAAQAAAABAAAAEwAAAAAChpcNAAAACXBIWXMAAAsTAAALEwEAmpwYAAABWWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS40LjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyI+CiAgICAgICAgIDx0aWZmOk9yaWVudGF0aW9uPjE8L3RpZmY6T3JpZW50YXRpb24+CiAgICAgIDwvcmRmOkRlc2NyaXB0aW9uPgogICA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgpMwidZAAABqElEQVQoFY3SPUvDQBgH8BREpRHExYiDgmLFl6WC+AYmWeyLg4i7buJX8DMpOujgyxGvUYeCgzhUQUSKKLUS0+ZyptXh8Z5Ti621ekPyJHl+uftfomhaf9Ei5JyxXKfynyEA6EYcLHpwyflT958GAQ7DTABNHd8EbtDbEH2BD5QEQmi2mM8P/Iq+A0SzszEg+3sPjDnDdVEtQKQbMUidHD3xVzf6A9UDEmEm+8h9KTqTVUjT+vB53aHrCbAPiceYq1dQI1Aqv4EhMll0jzv+Y0yiRgCnLRSYyDQHVoqUXe4uKL9l+L7GXC4vkMhE6eW/AOJs9k583ORDUyXMZ8F5SVHVVnllmPNKSFagAJ5DofaqGXw/gHBYg51dIldkmknY3tguv3jOtHR4+MqAzaraJXbEhqHhcQlwGSOi5pytVQHZLN5s0WNe8HPrLYlFsO20RPHkImxsbmHdLJFI76th7Z4SeuF53hTeFLvhRCJRCTKZKxgdnRDbW+iozFJbBMw14/ElwGYc0egMBMFzT21f5Rog33Z7dX02GBm7WV5ZfT5Nn5bE3zuCDe9UxdTpNvK+5AAAAABJRU5ErkJggg==',17}) => {18const canvasRef = useRef<HTMLCanvasElement | null>(null);19const cursorRef = useRef({ x: 0, y: 0 });20const particlesRef = useRef<21Array<{22position: { x: number; y: number };23image: HTMLImageElement;24move: (context: CanvasRenderingContext2D) => void;25}>26>([]);27const animationFrameRef = useRef<number>();28const cursorsInittedRef = useRef(false);2930class Particle {31position: { x: number; y: number };32image: HTMLImageElement;3334constructor(x: number, y: number, image: HTMLImageElement) {35this.position = { x, y };36this.image = image;37}3839move(context: CanvasRenderingContext2D) {40context.drawImage(this.image, this.position.x, this.position.y);41}42}4344useEffect(() => {45const baseImage = new Image();46baseImage.src = baseImageSrc;4748const prefersReducedMotion = window.matchMedia(49'(prefers-reduced-motion: reduce)'50);5152const hasWrapperEl = element !== undefined;53const targetElement = hasWrapperEl ? element : document.body;54const canvas = document.createElement('canvas');55const context = canvas.getContext('2d');5657if (!context) return;5859canvasRef.current = canvas;60canvas.style.top = '0px';61canvas.style.left = '0px';62canvas.style.pointerEvents = 'none';6364if (hasWrapperEl) {65canvas.style.position = 'absolute';66targetElement.appendChild(canvas);67canvas.width = targetElement.clientWidth;68canvas.height = targetElement.clientHeight;69} else {70canvas.style.position = 'fixed';71document.body.appendChild(canvas);72canvas.width = window.innerWidth;73canvas.height = window.innerHeight;74}7576const onMouseMove = (e: MouseEvent) => {77if (hasWrapperEl && element) {78const boundingRect = element.getBoundingClientRect();79cursorRef.current.x = e.clientX - boundingRect.left;80cursorRef.current.y = e.clientY - boundingRect.top;81} else {82cursorRef.current.x = e.clientX;83cursorRef.current.y = e.clientY;84}8586if (cursorsInittedRef.current === false) {87cursorsInittedRef.current = true;88for (let i = 0; i < particles; i++) {89particlesRef.current.push(90new Particle(cursorRef.current.x, cursorRef.current.y, baseImage)91);92}93}94};9596const onWindowResize = () => {97if (hasWrapperEl && element) {98canvas.width = element.clientWidth;99canvas.height = element.clientHeight;100} else {101canvas.width = window.innerWidth;102canvas.height = window.innerHeight;103}104};105106const updateParticles = () => {107context.clearRect(0, 0, canvas.width, canvas.height);108109let x = cursorRef.current.x;110let y = cursorRef.current.y;111112particlesRef.current.forEach((particle, index) => {113const nextParticle =114particlesRef.current[index + 1] || particlesRef.current[0];115116particle.position.x = x;117particle.position.y = y;118particle.move(context);119x += (nextParticle.position.x - particle.position.x) * rate;120y += (nextParticle.position.y - particle.position.y) * rate;121});122};123124const loop = () => {125updateParticles();126animationFrameRef.current = requestAnimationFrame(loop);127};128129if (!prefersReducedMotion.matches) {130targetElement.addEventListener('mousemove', onMouseMove);131window.addEventListener('resize', onWindowResize);132loop();133}134135return () => {136if (canvasRef.current) {137canvasRef.current.remove();138}139if (animationFrameRef.current) {140cancelAnimationFrame(animationFrameRef.current);141}142targetElement.removeEventListener('mousemove', onMouseMove);143window.removeEventListener('resize', onWindowResize);144};145}, [element, particles, rate, baseImageSrc]);146147return null;148};149150export default TrailingCursor;151
Prop | Type | Default | Description |
---|---|---|---|
element | HTMLElement | undefined | The HTML element to which the trailing cursor effect is applied. |
particles | number | 15 | The number of particles in the trailing cursor effect. |
rate | number | 0.4 | The rate of trailing cursor movement. |
baseImageSrc | string | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAATCAYAAACk9eypAAA...' (truncated) | Base64 image string used as the default particle for the cursor trail. |