I made this cursor animation recently, and people seem to like it :)
It is a nice-looking piece, but it's also quite simple and takes only 2KB of JS. Plus, the approach is quite universal and it can be used as a template for other beauties.
So it deserves a step-by-step guide!
Let's go
Step #1: Setup
We're drawing on the <canvas>
element and we need the <canvas>
to take the a full screen.
canvas {
position: fixed;
top: 0;
left: 0;
}
<canvas></canvas>
setupCanvas();
window.addEventListener("resize", setupCanvas);
function setupCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
And, for sure, we need to track the cursor position.
const pointer = {
x: .5 * window.innerWidth,
y: .5 * window.innerHeight,
}
window.addEventListener("click", e => {
updateMousePosition(e.pageX, e.pageY);
});
window.addEventListener("mousemove", e => {
updateMousePosition(e.pageX, e.pageY);
});
window.addEventListener("touchmove", e => {
updateMousePosition(e.targetTouches[0].pageX, e.targetTouches[0].pageY);
});
function updateMousePosition(eX, eY) {
pointer.x = eX;
pointer.y = eY;
}
Step #2: Animation loop
To see the simplest mouse-following animation, we only need to redraw canvas in a loop using the window.requestAnimationFrame()
method, and draw the circle centered as pointer coordinates on each step.
const p = {x: 0, y: 0}; // coordinate to draw
update(0);
function update(t) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// copy cursor position
p.x = poiner.x;
p.y = poiner.y;
// draw a dot
ctx.beginPath();
ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI);
ctx.fill();
window.requestAnimationFrame(update);
}
With the code above, we have a black circle following the mouse.
Step #3: Adding the delay
Now, the circle is following the cursor as fast as it can. Let's add a delay so the dot catches up with the target position in a somewhat elastic way.
const params = {
// ...
spring: .4
};
// p.x = poiner.x;
// p.y = poiner.y;
p.x += (pointer.x - p.x) * params.spring;
p.y += (pointer.y - p.y) * params.spring;
ctx.beginPath();
ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI);
ctx.fill();
The spring
parameter is used to determine how fast the dot will catch up with cursor position. A small value like .1
will make it follow very slowly, while spring = 1
means no delay.
Step #3: Creating mouse trail
Let's create a trail - array of points' data, with each point holding the x
/y
coordinates and dx
/dy
deltas which we use to calculate delay.
const params = {
// ...
pointsNumber: 30
};
// const p = {x: 0, y: 0};
const trail = new Array(params.pointsNumber);
for (let i = 0; i < params.pointsNumber; i++) {
trail[i] = {
x: poiner.x,
y: poiner.y,
dx: 0,
dy: 0,
}
}
Instead of a single dot, we draw now the whole trail where each dot is trying to catch up with the previous one. The first dot catches up with cursor coordinate (pointer
) and delay of this first point is longer - simply because it looks better for me :)
function update(t) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
trail.forEach((p, pIdx) => {
const prev = pIdx === 0 ? pointer : trail[pIdx - 1];
const spring = pIdx === 0 ? .4 * params.spring : params.spring;
p.dx = (prev.x - p.x) * spring;
p.dy = (prev.y - p.y) * spring;
p.x += p.dx;
p.y += p.dy;
ctx.beginPath();
ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI);
ctx.fill();
});
window.requestAnimationFrame(update);
}
Step #4: Turning dots to the line
It's easy to draw a polyline instead of dots.
trail.forEach((p, pIdx) => {
const prev = pIdx === 0 ? pointer : trail[pIdx - 1];
p.dx = (prev.x - p.x) * params.spring;
p.dy = (prev.y - p.y) * params.spring;
p.x += p.dx;
p.y += p.dy;
// ctx.beginPath();
// ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI);
// ctx.fill();
if (pIdx === 0) {
// start the line on the first point
ctx.beginPath();
ctx.moveTo(p.x, p.y);
} else {
// continue with new line segment to the following one
ctx.lineTo(p.x, p.y);
}
});
// draw the thing
ctx.stroke();
Step #5: Accumulating the speed
What makes the cursor animation really nice looking is accumulating the deltas. Let's use dx
/dy
not only for the distance to the neighbour position but accumulate this distance.
To prevent the delta values getting super big super fast, we're also multiplying dx
/dy
with new friction
parameter on each step.
const params = {
// ...
friction: .5
};
...
// ...
// p.dx = (prev.x - p.x) * spring;
// p.dy = (prev.y - p.y) * spring;
p.dx += (prev.x - p.x) * spring;
p.dy += (prev.y - p.y) * spring;
p.dx *= params.friction;
p.dy *= params.friction;
// as before
p.x += p.dx;
p.y += p.dy;
// ...
Step #6: Smooth line
The motion is done! Let's make the stroke look better and replace each line segment with Bézier curve.
trail.forEach((p, pIdx) => {
// calc p.x and p.y
if (pIdx === 0) {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
// } else {
// ctx.lineTo(p.x, p.y);
}
});
for (let i = 1; i < trail.length - 1; i++) {
const xc = .5 * (trail[i].x + trail[i + 1].x);
const yc = .5 * (trail[i].y + trail[i + 1].y);
ctx.quadraticCurveTo(trail[i].x, trail[i].y, xc, yc);
}
ctx.stroke();
Smooth!
Step #7: Play with line width
For this demo, the last step is replacing the default lineWidth
which is 1px to the dynamic value that gets smaller for the each segment.
const params = {
baseWidth: .9,
};
...
for (let i = 1; i < trail.length - 1; i++) {
// ...
ctx.quadraticCurveTo(trail[i].x, trail[i].y, xc, yc);
ctx.lineWidth = params.baseWidth * (params.pointsNumber - i);
}
See the source code on codepen.