Coding an Interactive (and Damn Satisfying) Cursor: 7 Simple Steps + 2kb of Code
Tags: javascript, animation, canvas, tutorial
Published: 2023-09-28 08:44:59

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!

{% codepen https://codepen.io/ksenia-k/pen/rNoBgbV %}

Let's go

## Step #1: Setup

We're drawing on the `<canvas>` element and we need the `<canvas>` to take the a full screen.

```css
canvas {
    position: fixed;
    top: 0;
    left: 0;
}
```

```html
<canvas></canvas>
```

```js
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.

```js
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.

```js
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.

![mouse following](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3h26j5twk5i4lb21odfk.gif)


## 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.

```js
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.


![mouse following with delay](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gtmt2m56jg6ngaav5g6p.gif)

## 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.

```js
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 :)

```js

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);
}
```

![mouse trail](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e6jqtmwv834zlemflttx.gif)


## Step #4: Turning dots to the line

It's easy to draw a polyline instead of dots.

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


![polyline](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q82lxqgb54gkco0fmcly.gif)
## 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.

```js
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;

// ...
```

![accumulated](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6g2s0in36ix9oqvvsp97.gif)


## Step #6: Smooth line

The motion is done! Let's make the stroke look better and replace each line segment with [Bézier curve](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/quadraticCurveTo). 

```js
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!


![smooth line](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8kl9l5pb0kf093usmae5.gif)


## 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.

```js
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);
}
```

![final curve](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ctylciwgdb9mdklhhvvy.gif)

See the source code on [codepen](https://codepen.io/ksenia-k/pen/rNoBgbV).