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.