Custom Cursors, Part Two: Implementation

Published
February 8, 2022
Length
~1327 words
Time
~7 minutes
Published February 8, 2022
~1327 words // ~7 minutes
Share on Twitter

Check out Part One for when and why you might want to add a custom cursor to the desktop experience of your web app. For now, let's dive into implementation.

We're going to create a custom cursor that has two parts, and use it across the entire app. Note also, however, that you could use a custom cursor for just one of the app's screens or even use different custom cursors for different parts of the same screen. Additionally, we're going to "magnetize" some elements so that the cursor feels drawn to them and stuck on them until we pull it away.

Of course, content and function come first. The cursor should just be an addon. If you're relying on it to communicate important info to the user, then I'd suggest revising your strategy.

Our starting point is going to be this simplistic playground, a single-screen "app" with merely a little text and a few buttons.

The playground.

 — 

Code hosted on CodeSandbox.

Let's hide the standard cursor across the whole app by adding cursor: none to the styles for <body> or some other element that contains your entire app. We'll also add it to the buttons since they have their cursor style that overrides the style we've set for the whole app.

Next, let's add two <div>s for the two parts of the custom cursor and give them some styling. We're essentially creating a dot with a circle around it, positioning them at the top left corner of the screen, and instructing them not to respond to pointer events such as clicking / tapping, hovering, etc. CSS changes made to the outer element will be delayed by 100 milliseconds in order to create a lagging / trailing effect.

<div class="container">
  <div class="cursor-element cursor-element--inner"></div>
  <div class="cursor-element cursor-element--outer"></div>
</div>
.container,
.container button {
  cursor: none;
}
.cursor-element {
  position: fixed;
  border-radius: 50%;
  pointer-events: none;
}
.cursor-element--inner {
  left: -2px;
  top: -2px;
  width: 4px;
  height: 4px;
  background-color: hsla(270,100%,70%,1);
}
.cursor-element--outer {
  left: -16px;
  top: -16px;
  width: 32px;
  height: 32px;
  border: 2px solid hsla(275,100%,43%,1);
  transition: all 100ms;
}

Custom cursor elements have been added and the standard cursor has been hidden.

 — 

Code hosted on CodeSandbox.

Having our objects in place, let's now add their behavior.

First, let's set up some variables to track some things. These are outside the scope of the functions we'll create because they'll be used across these functions. The code's comments explain what each one is for.

// the cutom cursor's inner and outer element DOM nodes
const outerElementDOMNode = document.querySelector('.cursor-element--outer');
const innerElementDOMNode = document.querySelector('.cursor-element--inner');
// user's pointing location in pixels from top left of screen; 
//		default to offscreen
let pointerLocationX = -100;
let pointerLocationY = -100;
// location of outer element when it's stuck; default to 
// 		0; will be changed before it's used
let stuckLocationX = 0;
let stuckLocationY = 0;
// array of query selectors for the DOM elements that 
// 		will be "magnetic"
const magneticNodeSelectors = ['button'];
// boolean tracking the stuckness of the outer element
let outerElementIsStuck = false;

Next, we'll define and call a function to start moving each part of the cursor. These functions look very similar for now, but they're separated because one will differ from the other soon and combining them creates a "too much recursion" error.

// define function to initialize the inner element
const initializeInnerElement = () => {
	// listen for the mousemove event and update our record of
	//		where the user is pointing
	document.addEventListener('mousemove', (event) => {
		pointerLocationX = event.clientX;
		pointerLocationY = event.clientY;
	});
	// define function that moves the innerElementDOMNode from
	// 		its default position to where the user is pointing,
	// 		and call the function recursively
	const positionInnerElement = () => {
		// set the DOM node's transform style
		innerElementDOMNode.style.cssText = 
			 `transform: translate(${pointerLocationX}px, ${pointerLocationY}px);`;
		// call self when an animation frame is available
		requestAnimationFrame(positionInnerElement);
	};
	// call the positionInnerElement function when an animation frame is available
	requestAnimationFrame(positionInnerElement);
};
// define function to initialize the outer element
const initializeOuterElement = () => {
	// listen for the mousemove event and update our record of
	//		where the user is pointing
	document.addEventListener('mousemove', (event) => {
		pointerLocationX = event.clientX;
		pointerLocationY = event.clientY;
	});
	// define function that moves the outerElementDOMNode from
	// 		its default position to where the user is pointing,
	// 		and call the function recursively
	const positionOuterElement = () => {
		// set the DOM node's style
		outerElementDOMNode.style.cssText = 
			`transform: translate(${pointerLocationX}px, ` + 
			`${pointerLocationY}px); border-width: 0.25rem;`;
		// call self when an animation frame is available
		requestAnimationFrame(positionOuterElement);
	};
	// call the positionOuterElement function when an animation frame is available
	requestAnimationFrame(positionOuterElement);
};
// initialize the custom cursor's movement by calling the 
// 		initialization functions we've defined
initializeInnerElement();
initializeOuterElement();

At this point, we've got our custom cursor effectively indicating where the user is pointing, with the outer element trailing the inner element by 100 milliseconds.

Custom cursor elements indicate where the user is pointing.

 — 

Code hosted on CodeSandbox.

Now, let's add a little embellishment. We're going to first change how we position the outer cursor element, allowing it to use the stuck location instead of the pointer location if the outerElementIsStuck flag is set to true. (We haven't set that flag yet, but we're about to.) Let's adjust the positionOuterElement function to the following.

// define function that moves the outerElementDOMNode from
// 		its default position to where the user is pointing,
// 		and call the function recursively
const positionOuterElement = () => {
	// if outer element is not stuck
	if (!outerElementIsStuck) {
		// set the DOM node's style to the pointer location
		outerElementDOMNode.style.cssText =
			`transform: translate(${pointerLocationX}px, ` +
			`${pointerLocationY}px); border-width: 0.25rem;`;
	// if outer element is stuck
	} else if (outerElementIsStuck) {
		// set the DOM node's style to the stuck location
		outerElementDOMNode.style.cssText =
			`transform: translate(${stuckLocationX}px, ${stuckLocationY}px) ` +
			`scale(2.2, 2.2); border-width: 0.125rem;`;
	}
	// call self when an animation frame is available
	requestAnimationFrame(positionOuterElement);
};

Next, we'll specify what should happen when the pointing device enters and leaves a magnetic element, and magnetize each of the desired elements by adding to them listeners for the mouseenter and mouseleave events.

// define function to specify what happens when
// 		the pointing device enters a magnetic element
const handleEnteringMagneticElement = (event) => {
	// get this element's size and position info
	const magneticElementBox = event.currentTarget.getBoundingClientRect();
	// set the stuck location's x-coordinate to this element's
	// 		left position and half of its width (the
	// 		element's horizontal center)
	stuckLocationX = Math.round(
		magneticElementBox.left + magneticElementBox.width / 2
	);
	// set the stuck location's y-coordinate to this element's
	// 		top position and half of its height (the
	// 		element's vertical center)
	stuckLocationY = Math.round(
		magneticElementBox.top + magneticElementBox.height / 2
	);
	// set flag indicating that the outer element is stuck
	outerElementIsStuck = true;
};
// define function to specify what happens when
// 		the pointing device leaves a magnetic element
const handleLeavingMagneticElement = () => {
	// set flag indicating that the outer element is not stuck
	outerElementIsStuck = false;
};
// define function to magnetize a single given element
const magnetizeElement = (node) => {
	// add to the given element the specified event
	// 		listeners and handlers
	node.addEventListener('mouseenter', handleEnteringMagneticElement);
	node.addEventListener('mouseleave', handleLeavingMagneticElement);
};
// define function to magnetize all elements in
// 		the magneticNodeSelectors array
const magnetizeAllMagneticElements = () => {
	// for each node selector in magneticNodeSelectors
	magneticNodeSelectors.forEach((nodeSelector) => {
		// for each node in the set of all nodes matching
		// 		the given selector
		document.querySelectorAll(nodeSelector).forEach((node) => {
			// call the magnetizeElement function on the node
			magnetizeElement(node);
		});
	});
};
// magnetize all of the magnetic elements
magnetizeAllMagneticElements();

And, voila, our final result.

Our final result.

 — 

Code hosted on CodeSandbox.

There's so much more you can do with this. Add a third DOM element to the mix. Make one of the elements fade in and out. Use shapes other than circles. Stop hiding the default cursors and only use the custom cursor as an enhancement. Within the scope of your use case, your imagination is the limit.