Mouse Trail

By Noah Yamamoto

I wanted to make something that would add some flair to my website, and after seeing another developer implement something similar on their website I figured I'd give it a shot. My first attempt was much simpler, just drawing a line from the last point to the new point then fading out the whole canvas. Check out version 1 here.

However this method had some issues and was very inflexible, so I eventually went about making a better version that keeps track of all active points and animates them individually. Perhaps a bit more complicated, but doing it by hand allows for much more customization without any noticeable drop in performance.

The new version works by keeping track of all visible points in an array and updating them all on every (requestAnimation)Frame:

class Point { constructor(x, y) { this.x = x; this.y = y; this.lifetime = 0; }
} const points = []; const addPoint = (x, y) => { const point = new Point(x, y); points.push(point);
}; document.addEventListener('mousemove', ({ clientX, clientY }) => { addPoint(clientX - canvas.offsetLeft, clientY - canvas.offsetTop);
}, false);
...

Each point gets a different color and width depending on how long its been alive until it reaches a set maximum lifetime and dies (is removed from the queue). This allows the trail to "fade" out into a different color before disappearing. In my example I have the point going from purple to blue as it fits the theme of my site:


const lifePercent = (point.lifetime / duration);
const spreadRate = 7 * (1 - lifePercent); ctx.lineJoin = 'round';
ctx.lineWidth = spreadRate; const red = Math.floor(190 - (190 * lifePercent));
const green = 0;
const blue = Math.floor(210 + (210 * lifePercent));
ctx.strokeStyle = `rgb(${red},${green},${blue}`;

Another concern of mine was mobile; for whatever reason it seems that some mobile devices emit the mousemove event on touch/drag, and this was causing weird jumpy cursor trails to appear for mobile users. Since smartphones don't (usually) have cursors anyways, I decided to just disable the animation if the user had no pointer device attached by checking a matchMedia conditional before starting it:

if (matchMedia('(pointer:fine)').matches) { this.startAnimation();
}

(This surprisingly has 98.12% support, how had I not heard of it before‽)

Anyways, throw all this into a component and you'll get a fancy mouse trail animation! Full code below:

import React from 'react'; class Point { constructor(x, y) { this.x = x; this.y = y; this.lifetime = 0; }
} class Canvas extends React.Component { state = { cHeight: 0, cWidth: 0, }; canvas = React.createRef(); componentDidMount = () => { this.setState({ cHeight: document.body.clientHeight, cWidth: document.body.clientWidth, }); window.addEventListener( 'resize', () => { this.setState({ cHeight: document.body.clientHeight, cWidth: document.body.clientWidth, }); }, false, ); if (matchMedia('(pointer:fine)').matches) { this.startAnimation(); } } startAnimation = () => { const canvas = this.canvas.current; const ctx = canvas.getContext('2d'); const points = []; const addPoint = (x, y) => { const point = new Point(x, y); points.push(point); }; document.addEventListener('mousemove', ({ clientX, clientY }) => { addPoint(clientX - canvas.offsetLeft, clientY - canvas.offsetTop); }, false); const animatePoints = () => { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); const duration = 0.7 * (1 * 1000) / 60; for (let i = 0; i < points.length; ++i) { const point = points[i]; let lastPoint; if (points[i - 1] !== undefined) { lastPoint = points[i - 1]; } else lastPoint = point; point.lifetime += 1; if (point.lifetime > duration) { points.shift(); } else { const lifePercent = (point.lifetime / duration); const spreadRate = 7 * (1 - lifePercent); ctx.lineJoin = 'round'; ctx.lineWidth = spreadRate; const red = Math.floor(190 - (190 * lifePercent)); const green = 0; const blue = Math.floor(210 + (210 * lifePercent)); ctx.strokeStyle = `rgb(${red},${green},${blue}`; ctx.beginPath(); ctx.moveTo(lastPoint.x, lastPoint.y); ctx.lineTo(point.x, point.y); ctx.stroke(); ctx.closePath(); } } requestAnimationFrame(animatePoints); }; animatePoints(); } render = () => { const { cHeight, cWidth } = this.state; return <canvas ref={this.canvas} width={cWidth} height={cHeight} />; }
} export default Canvas;

Thanks for reading, hope you find this useful!