Building a Custom Cursor
Pure JavaScript can still do a lot
Motivation
Animation libraries are huge - I don’t need most of the what they’re offering just to animate a custom cursor element.
The Basics
In a nutshell, the goal is to change the look of the cursor when it enters a particular element.
Clearly, it seems best to use CSS for adjusting the style and use JavaScript for triggering style changes. Now, this also means that the CSS controls the animation properties (duration, delay etc.) using transition-*
CSS properties.
In this blog post, there are two different cursor objects, inner
and outer
…
Getting Started
So, given cursor elements:
inner: HTMLSpanElement | null;
outer: HTMLSpanElement | null;
And another element el: HTMLElement
such that whenever the mouse enters el
, a specified class should be added to the class lists of inner
and outer
.
In other words:
function addAnimation(selector: string, className: string) {
const elements = document.querySelectorAll(selector);
elements.forEach((element) => {
element.addEventListener("mouseenter", () => {
inner?.classList.add(className);
outer?.classList.add(className);
});
element.addEventListener("mouseleave", () => {
inner?.classList.remove(className);
outer?.classList.remove(className);
});
});
}
// Usage
addAnimation("nav", "on-nav");
So, whenever the (real) cursor enters a <nav>
element, add the class on-nav
to the inner
and outer
elements.
Now, of course, we must define the on-nav
class e.g.:
.on-nav {
transform: scale(1.5);
}
And we can register as many animations and classes as we want.
addAnimation("button", "on-button");
addAnimation("b", "focus");
addAnimation("strong", "focus");
addAnimation("i", "focus");
addAnimation("em", "focus");
This implementation separates the cursor styling from the infrastructure needed to trigger it.
Following the Real Cursor
Now, we can animate the fake cursors, we need to make them move with the user’s mouse.
We can use addEventListener
to call a function on the mousemove
event which fires whenever the user moves their mouse. In other words:
window.addEventListener("mousemove", onMouseMove);
Now, what happens in onMouseMove
?
A Simplistic Implementation
The simplest version would take the mouse position - clientX
and clientY
and use this directly on inner
and outer
:
// I prefer arrow functions, sorry if you do not!
const onMouseMove = ({ clientX, clientY }: MouseEvent) => {
inner.style.left = clientX;
inner.style.top = clientY;
outer.style.left = clientX;
outer.style.top = clientY;
};
While this works, it has, at least in my experience, some visual glitches and skips. We can solve this leveraging browser features.
A Better Implementation
We can fix this using the requestAnimationFrame
function, which (MDN Docs, 2022):
tells the browser that you wish to perform an animation and requests that the browser calls a specified function to update an animation before the next repaint.
Put simply, it helps alleviate some of the flickering and stuttering you may have seen with the previous method. If you don’t get any visual problems, the first method should suffice.
Another good feature is that it pauses the animation when the website loses focus (e.g., user is on another tab or window). However, the logic inside the animation is not processor/GPU-intensive at all so this should not make too much of a difference in terms of performance and battery life.
Additionally, browser support is good, supporting 96.78% of all users (at the time of writing).
Now, we need some slight restructuring:
const onMouseMove = ({ clientX, clientY }: MouseEvent) => {
// Trigger render loop
requestAnimationFrame(animate);
};
const animate = () => {
inner.style.left = clientX;
inner.style.top = clientY;
outer.style.left = clientX;
outer.style.top = clientY;
// Keep looping
requestAnimationFrame(animate);
};
Stopping the Animation
Now we have a new problem: the animate
function now runs forever since we haven’t given it a way of stopping.
Intuitively, we should stop animating the cursor if the user stops moving the mouse.
To implement this, we need to keep track of the previous mouse position to then compare it to the current mouse position to see if the mouse has moved. The distance between mouse positions is given by the Euclidean distance between the two positions.
With this, we can now stop the animation if the user stopped moving the mouse:
// These store the previous mouse position
let mouseX = -400; // Set these negative so the fake cursors start off screen
let mouseY = -400;
// These store the current mouse position
let xPos = 0;
let yPos = 0;
const onMouseMove = ({ clientX, clientY }: MouseEvent) => {
// Update previous mouse position values
mouseX = clientX;
mouseY = clientY;
// Trigger render loop
requestAnimationFrame(animate);
};
const animate = () => {
// Animation logic from above
// Calculate the absolute difference between the previous and current mouse position
const dX = mouseX - xPos;
const dY = mouseY - yPos;
// Update current mouse position
xPos += dX;
yPos += dY;
// Stop render loop if the user stopped moving the mouse
const delta = Math.sqrt(Math.pow(dX, 2) + Math.pow(dY, 2));
if (delta === 0) return;
// Otherwise, keep looping
requestAnimationFrame(animate);
};
The cursor styles I use are much larger than the standard cursor (you may have seen them on the site, if you have JavaScript enabled) so the animation is stopped if delta
is less than 0.1. You can adjust this check to suit your needs.
Now, we should also cancel the request scheduled by requestAnimationFrame
and we can do so using cancelAnimationFrame
. The requestAnimationFrame
method returns a number - the ID which we can use in cancelAnimationFrame
as follows:
// These store the previous mouse position
let mouseX = -400; // Set these negative so the fake cursors start off screen
let mouseY = -400;
// These store the current mouse position
let xPos = 0;
let yPos = 0;
let animId: number | null = null;
const onMouseMove = ({ clientX, clientY }: MouseEvent) => {
mouseX = clientX;
mouseY = clientY;
// Trigger render loop if no render loop is currently running
if (!animId) animId = requestAnimationFrame(animate);
};
const animate = () => {
// Animation logic from above
// Calculate the absolute difference between the previous and current mouse position
const dX = mouseX - xPos;
const dY = mouseY - yPos;
// Update current mouse position
xPos += dX;
yPos += dY;
// Stop render loop if the user stopped moving the mouse
const delta = Math.sqrt(Math.pow(dX, 2) + Math.pow(dY, 2));
if (delta === 0) {
if (animId) {
cancelAnimationFrame(animId);
animId = null;
}
}
// Otherwise, keep looping
animId = requestAnimationFrame(animate);
};
Wrapping It All Up
Now, we can package all this into a single function that registers the handlers to make it easier to use:
function setupCursor() {
// These are the selectors I use, change them as needed.
inner = document.querySelector(".cursor-container .inner");
outer = document.querySelector(".cursor-container .outer");
useCursor(inner, outer); // A function which specifies all the addAnimation(..., ...) and registers the mouseenter and mouseleave events
window.addEventListener("mousemove", onMouseMove, false);
}
Note that if you are using a framework, you should not be using document.querySelector
- use the framework-suggested method of maintaining references to elements.
And that’s all we really need for a custom cursor.
Bonus
To improve aesthetics, we can add a slight delay to the cursor animations to make the custom cursors lag slightly behind the real cursor; this gives some notion of weight.
In my case, the two cursors are delayed by different amounts, using the setTimeout
method. Additionally, I do not use the left
and top
CSS properties to set the position of the custom cursors - I use transforms instead which are more performant.
const transform = `translate3d(calc(${xPos}px - 50%), calc(${yPos}px - 50%), 0)`;
setTimeout(() => (inner.style.transform = transform), 60);
setTimeout(() => (outer.style.transform = transform), 220);
You can add the animation logic inside the setTimeout
callback function.
I should add that you should not disable the real cursor i.e.:
* {
cursor: none !important;
}
With this method as a (seemingly) laggy cursor is sure to frustrate your users.
Conclusion
And that’s it - two weighted cursors in less than 100 lines of pure JavaScript. This code is used on this site and in other projects of mine (that use frameworks themselves).
For reference, here is the full source code:
function useCursor(
inner: HTMLSpanElement | null,
outer: HTMLSpanElement | null
) {
function addAnimation(selector: string, className: string) {
const elements = document.querySelectorAll(selector);
elements.forEach((element) => {
element.addEventListener("mouseenter", () => {
inner?.classList.add(className);
outer?.classList.add(className);
});
element.addEventListener("mouseleave", () => {
inner?.classList.remove(className);
outer?.classList.remove(className);
});
});
}
// Specify your animations here
}
let inner: HTMLSpanElement | null = null;
let outer: HTMLSpanElement | null = null;
let animId: number | null = null;
let mouseX = -400;
let mouseY = -400;
let xPos = 0;
let yPos = 0;
let dX = 0;
let dY = 0;
const animate = () => {
dX = mouseX - xPos;
dY = mouseY - yPos;
xPos += dX;
yPos += dY;
const transform = `translate3d(calc(${xPos}px - 50%), calc(${yPos}px - 50%), 0)`;
setTimeout(() => (inner.style.transform = transform), 60);
setTimeout(() => (outer.style.transform = transform), 220);
// Stop render loop if the user stopped moving the mouse
const delta = Math.sqrt(Math.pow(dX, 2) + Math.pow(dY, 2));
if (delta < 0.1) return stopCursor();
// Otherwise, keep looping
animId = requestAnimationFrame(animate);
};
// Stop animation loop
const stopCursor = () => {
if (animId) {
cancelAnimationFrame(animId);
animId = null;
}
};
const onMouseMove = ({ clientX, clientY }: MouseEvent) => {
mouseX = clientX;
mouseY = clientY;
// Trigger render loop if no render loop is currently running
if (!animId) animId = requestAnimationFrame(animate);
};
function setupCursor() {
inner = document.querySelector(".cursor-container .inner");
outer = document.querySelector(".cursor-container .outer");
useCursor(inner, outer);
window.addEventListener("mousemove", onMouseMove, false);
}
export { setupCursor };