Browse by section

Web Design 日本語

How to Build a Mouse Stalker That Trails the Cursor

A mouse stalker is a shape that trails behind the cursor. Beyond decoration, it helps people keep track of where the pointer is and can highlight what is clickable.

The short answer: hide the real cursor with cursor: none, then move a replacement element to the pointer coordinates. Delay that movement and it becomes a stalker.

This article covers two implementations — one with jQuery, one with no library at all using transform and requestAnimationFrame. It also covers the disabling rules you must include, because on a touch device this effect leaves the user with no cursor at all.

Sponsored

Replacing the mouse cursor

Hide the native cursor with cursor: none and follow the pointer with an element of your own.

The example below replaces it with a shape built in CSS.

The HTML

One element stands in for the cursor. Put an img inside it to use an image instead of a CSS shape.

<div id="cursor"></div>

The CSS

Set cursor: none on the body to hide the real pointer, and pointer-events: none on the replacement.

body {
	cursor: none;
	height: 200px;
}

#cursor {
	position: fixed;
	top: 0;
	left: 0;
	width: 40px;
	height: 40px;
	border-radius: 50%;
	background: #333;
	/* stop the cursor element swallowing clicks */
	pointer-events: none;
	/* centre it on the pointer position */
	margin: -20px 0 0 -20px;
}

pointer-events: none is not optional. Without it the replacement element sits under the pointer at all times and nothing on the page can be clicked.

The negative margin handles alignment. Assigning coordinates to top and left puts the element’s top-left corner at the pointer, so pulling it back by half its width and height centres it.

The jQuery

Read e.clientX and e.clientY on mousemove and assign them to the element.

const cursor = $("#cursor");

$(document).on("mousemove", function (e) {
	cursor.css({
		top: e.clientY,
		left: e.clientX,
	});
});

clientX and clientY are measured from the top-left of the viewport, which is the same origin position: fixed uses. No scroll compensation is needed.

Adding a trailing stalker

Add a second element and delay its movement.

<div id="cursor"></div>
<div id="stalker"></div>
#stalker {
	position: fixed;
	top: 0;
	left: 0;
	width: 60px;
	height: 60px;
	margin: -30px 0 0 -30px;
	border-radius: 50%;
	background: rgba(51, 51, 51, 0.3);
	pointer-events: none;
}
$(document).on("mousemove", function (e) {
	cursor.css({ top: e.clientY, left: e.clientX });

	setTimeout(function () {
		stalker.css({ top: e.clientY, left: e.clientX });
	}, 100);
});

This is short, but it has a flaw: it creates one timer per mouse movement. Move quickly and hundreds pile up, which makes the motion stutter. The next approach avoids that.

Sponsored

A smoother version with no library

Move the element a fraction of the remaining distance on every frame with requestAnimationFrame. No jQuery, no timers.

const cursor = document.getElementById("cursor");
const stalker = document.getElementById("stalker");

let mouseX = 0, mouseY = 0;
let stalkerX = 0, stalkerY = 0;

document.addEventListener("mousemove", (e) => {
	mouseX = e.clientX;
	mouseY = e.clientY;
	cursor.style.transform = `translate3d(${mouseX}px, ${mouseY}px, 0)`;
});

function loop() {
	// close 10% of the gap each frame; lower is slower
	stalkerX += (mouseX - stalkerX) * 0.1;
	stalkerY += (mouseY - stalkerY) * 0.1;
	stalker.style.transform = `translate3d(${stalkerX}px, ${stalkerY}px, 0)`;
	requestAnimationFrame(loop);
}
loop();

Because position now comes from transform, centring moves there too:

#cursor,
#stalker {
	position: fixed;
	top: 0;
	left: 0;
	pointer-events: none;
	margin: 0;
	translate: -50% -50%;
}

▼The two approaches compared

jQuery + setTimeout requestAnimationFrame + transform
Library jQuery required None
Positioning top / left transform
Rendering cost Triggers layout every time Compositing only
Fast movement Timers pile up and stutter Stays in sync with frames
Tuning the lag In milliseconds By coefficient (0.1 etc.)

Changing top and left forces the browser to recalculate layout. transform is handled during compositing. For something updating every frame, that difference is visible.

Rules you must include

Disable the effect on touch devices. Otherwise cursor: none applies with no replacement in sight.

Phones and tablets never fire mousemove, so the replacement element stays at its starting position while the real cursor stays hidden. Attach a mouse and there is nothing on screen at all.

Scope the whole thing to devices with a precise pointer:

/* only where a real pointer and hover exist */
@media (hover: hover) and (pointer: fine) {
	body {
		cursor: none;
	}

	#cursor,
	#stalker {
		display: block;
	}
}

#cursor,
#stalker {
	display: none;
}

Respect reduced-motion preferences too. People who have asked their OS to reduce motion often find trailing animations uncomfortable.

@media (prefers-reduced-motion: reduce) {
	body {
		cursor: auto;
	}

	#cursor,
	#stalker {
		display: none;
	}
}

Without these two blocks, some visitors end up with no visible cursor and an unusable page. This is decoration, so fall back to the native cursor wherever it cannot work properly.

Inverting the colours underneath

mix-blend-mode: difference inverts whatever the cursor overlaps, so it stays visible on any background.

#cursor {
	background: #fff;
	mix-blend-mode: difference;
}

Text under the cursor inverts as well, which keeps it readable while still reading as an effect. It works especially well on pages whose background colour changes from section to section.

To scale the cursor on hover, add a transition — but apply it to width or scale rather than transform, which is being rewritten every frame.

For an effect that also reads the pointer position, building drag-and-drop UI with Swapy covers a related implementation.