To create a polished user experience, we have to look beyond the obvious interactions. Paying close attention to cursor movements can provide valuable insights into a user’s intention.
This post explores a few interesting cursor movement techniques that use motion, pauses, and paths as meaningful signals.
Hover with intent
Sometimes users hover elements accidentally while moving their cursor across the screen. The mouseover event is often used to determine whether an element is active and perform actions such as displaying a tooltip.
By analyzing the movement pattern, we can infer whether the user is just passing over the element or they are intentionally hovering.
Try moving your cursor over these panels. The left one activates immediately, while the right one waits to confirm your intent:
Regular Hover
Activates immediately when you hover, even if you're just passing through.
Hover with Intent
Only activates when you pause or slow down, showing deliberate interest.
As you might have guessed, the trick is to wait for the cursor to stay still or slow down before firing the event listener. This is simplified implementation that illustrates the idea:
function addHoverIntent(element, onIntent, options = {}) {
const { sensitivity = 7, interval = 100 } = options;
let x = 0, y = 0, pX = 0, pY = 0;
let timer = null;
function track(e) {
x = e.clientX;
y = e.clientY;
}
function compare() {
timer = null;
// check if mouse has stayed still (within sensitivity threshold)
if (Math.abs(pX - x) + Math.abs(pY - y) < sensitivity) {
onIntent(); // user has paused - trigger intent
} else {
// mouse still moving - update position and check again
pX = x;
pY = y;
timer = setTimeout(compare, interval);
}
}
element.addEventListener('mouseenter', (e) => {
pX = e.clientX; pY = e.clientY;
element.addEventListener('mousemove', track);
timer = setTimeout(compare, interval);
});
element.addEventListener('mouseleave', () => {
if (timer) clearTimeout(timer);
element.removeEventListener('mousemove', track);
});
}
addHoverIntent(element, () => {
console.log('User showed intent!');
});
CSS-only solution
An easier CSS-only mitigation for accidental hovers is to add a small transition delay. This is often sufficient and avoids all that JavaScript! For example, if we want to show tooltips on hover, adding a transition-delay on the hover-in state ensures tooltips don’t appear during brief pass-throughs:
.menu-item .tooltip {
opacity: 0;
transform: translateY(4px);
pointer-events: none;
transition: opacity 140ms ease, transform 140ms ease;
transition-delay: 0ms;
}
.menu-item:hover .tooltip {
opacity: 1;
transform: translateY(0);
transition-delay: 300ms;
}
This works well for simple components, although it is less precise than the JavaScript version, and won’t handle more advanced use cases.
Hover and scroll
Consider an infinite canvas where scrolling controls the canvas viewport. Inside the canvas, we have elements that are themselves scrollable. Always passing through the scroll event to the canvas leaves us unable to scroll them:
A naive solution is to make the cards active on hover. This works for enabling scrolling, but is not ideal, as scrolling around the canvas is going to block as soon as a card is hit (try scrolling around passing over a card):
This is where hover intent comes in. We’ll make the cards active and enable scrolling only when the user intentionally hovers over them:
Attraction
When designing UIs, we often refer to Fitts’s law. The formula given in textbooks can seem intimidating:
But in its basic form, Fitts’s law says that a target the user wants to hit should be bigger and closer.
In other words, the time to reach a target depends on two things: the distance to it, and its width . To make a target easier to hit, you either make it bigger or bring it closer. Normally these are fixed geometric facts of the layout. However, with a bit of magic, we can cheat and rewrite the geometry of the interaction itself!
Magnetism
Magnetic targets feel like they “pull” the cursor as you approach them. In Fitts’s terms, we’re reducing — the button drifts toward the cursor, shortening the distance the user has to travel.
In the context of a browser, we can’t control the cursor itself and move it towards the target. Instead, we can reduce the distance by moving the target towards the cursor:
function addMagneticEffect(buttonWrapper, button) {
const magnetRadius = 100;
const maxMove = 15;
buttonWrapper.addEventListener('mousemove', (e) => {
const rect = button.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const distX = e.clientX - centerX;
const distY = e.clientY - centerY;
const distance = Math.sqrt(distX * distX + distY * distY);
if (distance < magnetRadius) {
// Calculate magnetic pull strength (stronger when closer)
const pull = 1 - (distance / magnetRadius);
// Move button toward cursor
const moveX = (distX / distance) * maxMove * pull;
const moveY = (distY / distance) * maxMove * pull;
button.style.transform = `translate(${moveX}px, ${moveY}px)`;
}
});
buttonWrapper.addEventListener('mouseleave', () => {
// Reset position when cursor leaves
button.style.transform = '';
});
}
This is a simple example that can work in certain UIs. However, the effect can seem forceful and unexpected. A more subtle approach from the user’s perspective would be to move the cursor itself.
Warp fields
While we can’t control the actual cursor in a browser, we can create the illusion of magnetic attraction by hiding it (cursor: none) and rendering our own — reducing from the other direction. The effect is more subtle and feels like “the cursor wants to go there”:
To implement a fake cursor, we have to use a canvas, which adds some amount of complexity:
function createWarpField(canvas, target) {
const ctx = canvas.getContext('2d');
const warpRadius = 150;
const warpStrength = 0.3;
let realMouse = { x: 0, y: 0 };
let fakeCursor = { x: 0, y: 0 };
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
realMouse.x = e.clientX - rect.left;
realMouse.y = e.clientY - rect.top;
// Calculate warp effect
const targetRect = target.getBoundingClientRect();
const targetX = targetRect.left - rect.left + targetRect.width / 2;
const targetY = targetRect.top - rect.top + targetRect.height / 2;
const dx = targetX - realMouse.x;
const dy = targetY - realMouse.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < warpRadius) {
// Warp cursor toward target
const warp = (1 - distance / warpRadius) * warpStrength;
fakeCursor.x = realMouse.x + dx * warp;
fakeCursor.y = realMouse.y + dy * warp;
} else {
fakeCursor.x = realMouse.x;
fakeCursor.y = realMouse.y;
}
drawCursor();
});
function drawCursor() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw fake cursor
ctx.beginPath();
ctx.arc(fakeCursor.x, fakeCursor.y, 5, 0, Math.PI * 2);
ctx.fillStyle = 'white';
ctx.fill();
}
}
A small caveat: removing the system cursor has real accessibility costs: it breaks screen readers and assistive tools that track pointer position. However, this technique works in controlled contexts such as games or purposefully interactive tools. This shouldn’t be the default UI for a marketing page!
Magnetic guidelines
In design tools like Figma, alignment guides appear when elements are positioned relative to each other. Rendering the guides is a good first step, but a magnetic effect helps the user snap on the target more easily, helping users land perfectly straight layouts. In Fitts’s terms, snapping increases : the effective target width becomes the entire snap zone, not a 1px line:
The idea: while dragging a card, compare its edges to every other card’s edges. If any pair is close enough, snap to align and draw a guide. Here’s a simplified implementation that checks left/top edges:
const SNAP_DISTANCE = 40;
function findAlignments(draggedCard, otherCards) {
const snaps = { x: null, y: null };
for (const card of otherCards) {
// How far is our left edge from their left edge?
const dLeft = Math.abs(draggedCard.x - card.x);
if (dLeft < SNAP_DISTANCE && (!snaps.x || dLeft < snaps.x.distance)) {
snaps.x = { target: card.x, distance: dLeft, guideX: card.x };
}
// How far is our top edge from their top edge?
const dTop = Math.abs(draggedCard.y - card.y);
if (dTop < SNAP_DISTANCE && (!snaps.y || dTop < snaps.y.distance)) {
snaps.y = { target: card.y, distance: dTop, guideY: card.y };
}
}
return snaps;
}
function onDrag(e) {
draggedCard.x = e.clientX - offset.x;
draggedCard.y = e.clientY - offset.y;
const snaps = findAlignments(draggedCard, otherCards);
// Snap to aligned position
if (snaps.x) draggedCard.x = snaps.x.target;
if (snaps.y) draggedCard.y = snaps.y.target;
// Draw guidelines where snaps occurred
if (snaps.x) drawVerticalGuide(snaps.x.guideX);
if (snaps.y) drawHorizontalGuide(snaps.y.guideY);
}
In practice, you’d also compare right-to-right, center-to-center, and cross-edge combinations (e.g. left-to-right for equal spacing). The snap itself is an immediate position override: when the distance falls below the threshold, we set the coordinate to the target value, producing the “magnetic snap” feel.
Repulsion
Magnetic attraction helps align elements, but sometimes we want the opposite effect: repulsion that prevents overlapping. This isn’t about reducing or increasing ; it’s about making mistakes harder to make.
For example, we might not want these cards to overlap. We can nudge the user to avoid overlapping them as they move closer:
The physics are straightforward: calculate the distance between card centers, and if they’re too close, push the non-dragged card away. The force increases as the distance decreases — think of it like two magnets with the same pole facing each other:
const REPULSION_RADIUS = 150;
const REPULSION_STRENGTH = 2.0;
function applyRepulsion(draggedCard, otherCard) {
const dx = otherCard.centerX - draggedCard.centerX;
const dy = otherCard.centerY - draggedCard.centerY;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= REPULSION_RADIUS || distance < 1) return;
// Normalized direction: from dragged → other (push away)
const nx = dx / distance;
const ny = dy / distance;
// Closer = stronger push (inverse relationship)
const strength = (1 - distance / REPULSION_RADIUS) * REPULSION_STRENGTH;
// Apply force to the other card's velocity
otherCard.vx += nx * strength;
otherCard.vy += ny * strength;
}
function animate() {
for (const card of cards) {
if (card === draggedCard) continue;
applyRepulsion(draggedCard, card);
// Apply velocity with damping (friction)
card.x += card.vx;
card.y += card.vy;
card.vx *= 0.92;
card.vy *= 0.92;
}
render();
requestAnimationFrame(animate);
}
The 0.92 damping factor acts as friction — without it, the cards would bounce forever. With it, the card slides away smoothly and comes to rest. The 1 - distance / radius formula means the force is zero at the edge of the repulsion radius and strongest when cards nearly overlap, creating a natural-feeling invisible boundary.
Safe triangles
When navigating nested menus, users often move their cursor diagonally toward a submenu item. The submenu can close prematurely when the cursor briefly exits the menu area. To illustrate this, try moving your cursor from a menu item to its submenu in this naive implementation. The submenu closes as soon as your cursor leaves the parent item:
Safe triangles create an invisible triangular zone that keeps the menu open during this natural movement pattern. With safe triangles, the menu stays open when your cursor moves through the invisible triangular zone toward the submenu:
The core idea is simple: when a submenu opens, we draw an invisible triangle from the cursor to the submenu’s edges. As long as the cursor stays inside this triangle, we assume the user is moving toward the submenu and keep it open. To check if a point is inside a triangle, we use cross products: if the point is on the same side of all three edges, it’s inside. This is a (100% vibe-coded) starter implementation:
// Check if point (px, py) is inside triangle (ax,ay)→(bx,by)→(cx,cy)
function isInsideTriangle(px, py, ax, ay, bx, by, cx, cy) {
// Cross product tells us which side of a line a point is on.
// If the sign is the same for all three edges, the point is inside.
const d1 = (px - bx) * (ay - by) - (ax - bx) * (py - by);
const d2 = (px - cx) * (by - cy) - (bx - cx) * (py - cy);
const d3 = (px - ax) * (cy - ay) - (cx - ax) * (py - ay);
const hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
const hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
return !(hasNeg && hasPos);
}
function addSafeTriangle(menu, submenu, menuItem) {
let safeZone = null;
menuItem.addEventListener('mouseenter', () => {
submenu.style.display = 'block';
});
// When cursor leaves the menu item, don't close immediately —
// start tracking whether cursor is inside the safe triangle
menuItem.addEventListener('mouseleave', (e) => {
const subRect = submenu.getBoundingClientRect();
// Triangle: cursor position → submenu top-left → submenu bottom-left
safeZone = {
cursorX: e.clientX,
cursorY: e.clientY,
topX: subRect.left,
topY: subRect.top,
bottomX: subRect.left,
bottomY: subRect.bottom,
};
});
// Track cursor movement across the entire menu area
menu.addEventListener('mousemove', (e) => {
if (!safeZone) return;
const inside = isInsideTriangle(
e.clientX, e.clientY,
safeZone.cursorX, safeZone.cursorY,
safeZone.topX, safeZone.topY,
safeZone.bottomX, safeZone.bottomY,
);
if (!inside) {
submenu.style.display = 'none';
safeZone = null;
}
});
// Clear safe zone when entering the submenu (we made it!)
submenu.addEventListener('mouseenter', () => {
safeZone = null;
});
}
The triangle’s three vertices are: the cursor position at the moment it leaves the menu item, and the top-left and bottom-left corners of the submenu. This covers the natural diagonal path a user takes when moving toward the submenu. Any movement outside this corridor closes the menu, because the user is moving somewhere else.
Conclusion
Every click is preceded by a journey. These techniques treat that journey as data: the pause before a hover, the arc toward a submenu, the approach angle to a button. But there’s something interesting going on: some of these techniques read intent, while others quietly shape it. For example, a warp field doesn’t just observe what the user wants: it bends the interaction so the user wants what you designed. The line between interpreting intention and manufacturing it is thinner than it looks, and the best interfaces live right on that edge.