usePointerPosition: Track Cursor & Touch Position in React
Introduction
Most "mouse position" hooks listen for mousemove, which silently breaks on touchscreens and styluses. The Pointer Events API unifies mouse, touch, and pen input behind a single event, so one listener is enough to cover them all.
This snippet provides a usePointerPosition hook that tracks the pointer's coordinates in React state — optionally scoped to a single element instead of the whole viewport — plus a useMousePosition alias for anyone migrating code that expects that name.
The Hook
import { useEffect, useState, type RefObject } from "react";
interface PointerPosition {
x: number;
y: number;
pointerType: "mouse" | "touch" | "pen" | null;
}
export function usePointerPosition(target?: RefObject<HTMLElement | null>): PointerPosition {
const [position, setPosition] = useState<PointerPosition>({ x: 0, y: 0, pointerType: null });
useEffect(() => {
const node = target?.current ?? window;
function handlePointerMove(event: PointerEvent) {
const rect = target?.current?.getBoundingClientRect();
setPosition({
x: rect ? event.clientX - rect.left : event.clientX,
y: rect ? event.clientY - rect.top : event.clientY,
pointerType: event.pointerType as PointerPosition["pointerType"]
});
}
node.addEventListener("pointermove", handlePointerMove as EventListener);
return () => node.removeEventListener("pointermove", handlePointerMove as EventListener);
}, [target]);
return position;
}
// Drop-in replacement for code that expects the mouse-only name —
// pointermove already fires for mouse input, so nothing is lost.
export const useMousePosition = usePointerPosition;How It Works
- Defaults to listening on
window, or on a specific element when atargetref is passed in. - On every
pointermove, readsclientX/clientYfrom the nativePointerEvent. - If a
targetis given, subtracts the element'sgetBoundingClientRect()offset so coordinates are relative to that element, not the viewport. - Also captures
event.pointerType("mouse","touch", or"pen"), so consumers can adapt behavior per input device. - Listener is attached and torn down inside
useEffect, keyed ontargetso it re-subscribes if the ref's target changes.
Example Usage
// Whole-viewport tracking
function CursorReadout() {
const { x, y, pointerType } = usePointerPosition();
return <p>{pointerType ?? "idle"} at ({x}, {y})</p>;
}
// Scoped to a single element — a spotlight effect that follows the pointer
function SpotlightCard({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const { x, y } = usePointerPosition(ref);
return (
<div
ref={ref}
style={{
position: "relative",
overflow: "hidden",
background: `radial-gradient(200px at ${x}px ${y}px, rgba(255,255,255,0.15), transparent 80%)`
}}
>
{children}
</div>
);
}Use Cases
- Spotlight / glow hover effects on cards, scoped to the element instead of the whole page.
- Custom cursors or drag previews that need to track pointer position across input types.
- Whiteboard or drawing tools that must handle mouse, touch, and stylus input identically.
- Analytics/heatmap prototypes that log
pointerTypealongside coordinates. - Tooltips or context menus that position themselves relative to a container, not the viewport.
Notes & Tips
- Throttle for high-frequency UI:
pointermovefires very often — wrap the setter in arequestAnimationFrameor debounce if you're driving expensive re-renders or animations. - SSR-safe by construction: the listener only attaches inside
useEffect, which never runs on the server, so there's nowindow is not definedrisk. pointerTypeoveruseMediaQuery("(pointer: coarse)"): when you need to react to this specific interaction rather than the device's general capability,pointerTypeis the more accurate signal.- Touch scrolling: if you use this inside a scrollable touch area, you may need
touch-action: noneon the target to stop the browser from treating the drag as a scroll gesture. - One listener, not three: resist the urge to also attach
mousemove/touchmovehandlers alongside this — Pointer Events already supersede both.
Key Takeaway
usePointerPosition replaces separate mouse/touch tracking logic with a single Pointer Events listener, adds element-relative coordinates via an optional target ref, and stays a drop-in useMousePosition for existing call sites — one hook that works correctly across every pointing device.
Changelog
- — Initial publication.