Coding an Interactive (and Damn Satisfying) Cursor: 7 Simple Steps + 2kb of Code

Ksenia Kondrashova - Sep 28 '23 - - Dev Community

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;
}
Enter fullscreen mode Exit fullscreen mode
<canvas></canvas>
Enter fullscreen mode Exit fullscreen mode
setupCanvas();
window.addEventListener("resize", setupCanvas);

function setupCanvas() {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

With the code above, we have a black circle following the mouse.

mouse following

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();
Enter fullscreen mode Exit fullscreen mode

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.

mouse following with 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,
    }
}

Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

mouse trail

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();
Enter fullscreen mode Exit fullscreen mode

polyline

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;

// ...
Enter fullscreen mode Exit fullscreen mode

accumulated

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();

Enter fullscreen mode Exit fullscreen mode

Smooth!

smooth line

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);
}
Enter fullscreen mode Exit fullscreen mode

final curve

See the source code on codepen.

. . .
Terabox Video Player