Build a Cursor Tracker
Capture pointer position on `pointermove` over a container; render a custom cursor element following with `transform: translate(x, y)` via rAF (not React state — avoid re-render storms). Handle enter/leave to show/hide; use pointer events for cross-input (mouse/pen/touch); throttle to rAF; cleanup listeners on unmount.
A custom cursor / tracker is a small but instructive exercise: it tests rAF, transform, event cleanup, and the importance of not re-rendering on every mouse move.
The naive (bad) version
function BadTracker() {
const [pos, setPos] = useState({ x: 0, y: 0 });
return (
<div onPointerMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
<div style={{ transform: `translate(${pos.x}px, ${pos.y}px)` }} />
</div>
);
}This re-renders the whole tree on every pixel of movement. Smooth for a toy, terrible in a real app.
The good version — refs + rAF + transform
function CursorTracker() {
const containerRef = useRef(null);
const cursorRef = useRef(null);
const target = useRef({ x: 0, y: 0 });
const rafId = useRef(null);
useEffect(() => {
const container = containerRef.current;
const cursor = cursorRef.current;
const update = () => {
cursor.style.transform = `translate(${target.current.x}px, ${target.current.y}px)`;
rafId.current = null;
};
const onMove = (e) => {
target.current.x = e.clientX;
target.current.y = e.clientY;
if (rafId.current == null) rafId.current = requestAnimationFrame(update);
};
const onLeave = () => cursor.style.opacity = "0";
const onEnter = () => cursor.style.opacity = "1";
container.addEventListener("pointermove", onMove);
container.addEventListener("pointerleave", onLeave);
container.addEventListener("pointerenter", onEnter);
return () => {
container.removeEventListener("pointermove", onMove);
container.removeEventListener("pointerleave", onLeave);
container.removeEventListener("pointerenter", onEnter);
if (rafId.current) cancelAnimationFrame(rafId.current);
};
}, []);
return (
<div ref={containerRef} style={{ position: "relative", height: "100vh", cursor: "none" }}>
<div
ref={cursorRef}
style={{
position: "fixed",
top: 0, left: 0,
width: 20, height: 20,
borderRadius: "50%",
background: "white",
mixBlendMode: "difference",
pointerEvents: "none",
opacity: 0,
willChange: "transform",
transition: "opacity 0.2s",
}}
/>
</div>
);
}What's load-bearing here
Refs, not state
Mouse position changes 60+ times per second. Don't put it in React state — store in a ref and write directly to the DOM via style.transform. React isn't the right tool for per-frame updates.
rAF throttling
Coalesce many pointermove events into one DOM write per frame. The pattern: "schedule a frame if one isn't already scheduled; record latest target."
Transform, not top/left
transform: translate is a composite-only property — no layout, no paint. top/left would trigger layout each frame. (See [[critical-rendering-path-crp-how-the-browser-renders-a-page]].)
pointer events, not mouse
pointermove is the unified API for mouse, pen, and touch. mousemove misses touch; touchmove misses pen.
pointer-events: none
The cursor element shouldn't catch its own events — otherwise it intercepts clicks and breaks the page underneath.
Cleanup
removeEventListener and cancelAnimationFrame on unmount, or you leak listeners (memory + zombie rAF callbacks).
Polish
- Lerp for smooth lag:
x += (target.x - x) * 0.2per frame, gives a trailing effect. - Magnetic on hover — detect
pointerenteron interactive elements and snap. - Hide on touch — touch devices don't have a hover cursor; check
pointerType === "touch". - Reduced motion — respect
prefers-reduced-motion: reduce(skip lerp, skip the custom cursor entirely).
Interview framing
"Don't put mouse position in React state — it'd re-render on every pixel. Listen for pointermove (unified for mouse/pen/touch), store the target position in a ref, schedule one rAF if not already scheduled, write directly to the element's style.transform. Transform is composite-only, no layout. pointer-events: none on the cursor so it doesn't catch its own events. Clean up listeners and rAF on unmount. Polish: lerp for trailing motion, hide on touch, respect prefers-reduced-motion."
Follow-up questions
- •Why refs and direct DOM writes instead of React state?
- •Why pointer events instead of mouse events?
- •Why animate transform instead of top/left?
- •What does pointer-events: none do here?
Common mistakes
- •Mouse position in React state — re-render storm.
- •Animating top/left → layout per frame.
- •No rAF — DOM write per pointermove (often 60+ per second).
- •Forgetting pointer-events: none — cursor blocks underlying interactions.
- •No cleanup — leaks listeners and rAF.
Performance considerations
- •rAF caps writes to ~60/sec (or vsync). Transform-only avoids layout/paint. willChange: transform hints the compositor to promote. Avoid forcing layout (offsetTop reads) inside the move handler.
Edge cases
- •Touch devices — no hover cursor; hide it.
- •Scrolling while moving — clientX/Y is viewport-relative, fine.
- •Multiple pointers (multi-touch) — pointerId disambiguates.
- •prefers-reduced-motion users.
Real-world examples
- •Apple's vision.apple.com custom cursor.
- •Awwwards-style portfolio sites.
- •Cursor-following tooltips, hover effects.