Ernest Badu - Ernest Badu
Ernest Badu - Ernest Badu
Ernest Badu - Ernest Badu
Ernest Badu - Ernest Badu
Ernest Badu - Ernest Badu
Ernest Badu - Ernest Badu
Ernest Badu - Ernest Badu

Written by

  • Contributor Ernxst

Published on

Last updated

8 min read

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 };
Ernest Nkansah-Badu