DEV Community

Cover image for Coding an Interactive (and Damn Satisfying) Cursor: 7 Simple Steps + 2kb of Code
Ksenia Kondrashova
Ksenia Kondrashova

Posted on

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

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.clientX, e.clientY);
});
window.addEventListener("mousemove", e => {
    updateMousePosition(e.clientX, e.clientY);
});
window.addEventListener("touchmove", e => {
    updateMousePosition(e.targetTouches[0].clientX, e.targetTouches[0].clientY);
});

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.

Top comments (14)

Collapse
 
the_riz profile image
Rich Winter • Edited

Nicely done. Especially with the smooth quadratic curve.

You could consider moving a lot of this into a class in order to keep it out of global scope. Also, though it works, I think it's a little awkward to call functions before they are defined. I like to place my event listeners all together at the end of a code block.

Collapse
 
uuuuuulala profile image
Ksenia Kondrashova

Hey Rich! It all makes perfect sense, thank you for the code review! I do have a tendency to concentrate on the visual aspect so it's always nice to have someone to take a look at my JS

Collapse
 
arkalsekar profile image
Abdul Rehman Kalsekar

Great Project πŸ‘

Collapse
 
uuuuuulala profile image
Ksenia Kondrashova

Thanks man

Collapse
 
bishwajit2810 profile image
Bishwajit Chakraborty

Nice project

Collapse
 
hasanelsherbiny profile image
Hasan Elsherbiny

amazing

Collapse
 
atamlk profile image
AtaMLK

amazing job

Collapse
 
bytebricks profile image
ByteBricks.ai

Soooooo satisfying! I can spend hours playing around with this!

Collapse
 
artydev profile image
artydev

Very nice, thank you
Look at processing.js...

Collapse
 
blindfish3 profile image
Ben Calder

I don't think processing.js has been actively maintained for quite some time. That was replaced by p5.js but TBH I think they tried too hard to match processing's Java based syntax in js; which I found rather limiting.
For anything canvas based my preference these days is pixiJS :)

Collapse
 
artydev profile image
artydev

PixiJS is indeed a great library
May I also suggest PaperJS

Collapse
 
gyansetu1 profile image
gyansetu

Gyansetu's Programming courses are a fantastic way to kickstart your coding journey. With experienced instructors and a variety of programming languages to choose from, it's a great platform for honing your skills and embracing the world of coding. Highly recommended!
For more info:- gyansetu.in/blogs/70-power-bi-mcq-...

Collapse
 
uuuuuulala profile image
Ksenia Kondrashova

My first spam comment here on dev.to! Taste of success πŸ˜„

Collapse
 
yoursirbee722 profile image
yorrsirbee722

this is the uprgarted version of that code in only html

<!DOCTYPE html>







Interactive Cursor Tutorial

<br>
/* Resetting default styles and adding custom styles <em>/<br>
body,<br>
html {<br>
padding: 0;<br>
margin: 0;<br>
overscroll-behavior: none;<br>
background-color: #111; /</em> Dark background color <em>/<br>
color: #fff; /</em> Text color /<br>
}</p>
<div class="highlight"><pre class="highlight plaintext"><code> /
Styling for the tutorial link */
.links {
position: fixed;
bottom: 10px;
right: 10px;
font-size: 18px;
font-family: sans-serif;
background-color: white;
padding: 10px;
}
a {
    text-decoration: none;
    color: black;
    margin-left: 1em;
}

a:hover {
    text-decoration: underline;
}

a img.icon {
    display: inline-block;
    height: 1em;
    margin: 0 0 -0.1em 0.3em;
}

/* Styling for the canvas */
canvas {
    position: absolute;
    top: 0;
    left: 0;
}
Enter fullscreen mode Exit fullscreen mode

&lt;/style&gt;
</code></pre></div>
<p></head></p>

<p><body><br>
&lt;!-- Canvas element for the interactive cursor animation --&gt;<br>
<canvas></canvas></p>
<div class="highlight"><pre class="highlight plaintext"><code>&lt;!-- Tutorial link with icon --&gt;
&lt;div class="links"&gt;
&lt;a href="dev.to/uuuuuulala/coding-an-intera..." target="_blank"&gt;tutorial&lt;img class="icon" src="https://ksenia-k.com/img/icons/link.svg"&amp;gt;&amp;lt;/a&amp;gt;
&lt;/div&gt;

&lt;!-- JavaScript for interactive cursor animation --&gt;
&lt;script&gt;
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext('2d');

// for intro motion
let mouseMoved = false;

const pointer = {
    x: .5 * window.innerWidth,
    y: .5 * window.innerHeight,
}
const params = {
    pointsNumber: 40,
    widthFactor: .3,
    mouseThreshold: .6,
    spring: .4,
    friction: .5
};

const trail = new Array(params.pointsNumber);
for (let i = 0; i &amp;lt; params.pointsNumber; i++) {
    trail[i] = {
        x: pointer.x,
        y: pointer.y,
        dx: 0,
        dy: 0,
    }
}

window.addEventListener("click", e =&amp;gt; {
    updateMousePosition(e.pageX, e.pageY);
});
window.addEventListener("mousemove", e =&amp;gt; {
    mouseMoved = true;
    updateMousePosition(e.pageX, e.pageY);
});
window.addEventListener("touchmove", e =&amp;gt; {
    mouseMoved = true;
    updateMousePosition(e.targetTouches[0].pageX, e.targetTouches[0].pageY);
});

function updateMousePosition(eX, eY) {
    pointer.x = eX;
    pointer.y = eY;
}

setupCanvas();
update(0);
window.addEventListener("resize", setupCanvas);

function update(t) {
    // for intro motion
    if (!mouseMoved) {
        pointer.x = (.5 + .3 * Math.cos(.002 * t) * (Math.sin(.005 * t))) * window.innerWidth;
        pointer.y = (.5 + .2 * (Math.cos(.005 * t)) + .1 * Math.cos(.01 * t)) * window.innerHeight;
    }

    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.strokeStyle = '#00ff00'; // Green color for the cursor trail

    trail.forEach((p, pIdx) =&amp;gt; {
        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.dx *= params.friction;
        p.dy *= params.friction;
        p.x += p.dx;
        p.y += p.dy;
    });

    ctx.beginPath();
    ctx.moveTo(trail[0].x, trail[0].y);

    for (let i = 1; i &amp;lt; 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.lineWidth = params.widthFactor * (params.pointsNumber - i);
        ctx.stroke();
    }
    ctx.lineTo(trail[trail.length - 1].x, trail[trail.length - 1].y);
    ctx.stroke();

    window.requestAnimationFrame(update);
}

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

&lt;/script&gt;
</code></pre></div>
<p></body></p>

<p></html></p>

<p>this is only html but fully working try thanks me latter</p>