JavaScript Get Mouse Coordinates
Every tutorial tells you clientX exists. Almost none show you why it gives a different number than offsetX -- with the proof right in front of you.
Live Demo: Four Coordinate Systems at Once
Move your mouse over the gridded area below. All four coordinate systems update simultaneously. This is the fastest way to understand the difference between them -- you can literally see screenX stay larger than clientX because it counts from your physical monitor corner, not the browser viewport.
Move your mouse over this area
pageY grows but clientY stays the same.The Basics: mousemove in One Minute
Every mouse coordinate in JavaScript starts with the mousemove event. You attach a listener, read the event object, and you get X and Y values. That is the entire foundation:
document.addEventListener('mousemove', function(e) {
console.log(e.clientX, e.clientY);
});
The event object e carries a stack of coordinate properties. The four you will actually use are offsetX, clientX, pageX, and screenX (plus their Y counterparts). They all describe the same physical mouse position, but measured from different reference points.
The mistake most tutorials make: they show you clientX and call it a day. But when you build a real drag-and-drop, a canvas drawing tool, or a tooltip positioning system, picking the wrong coordinate property causes subtle, hair-pulling bugs. The demo above exists so you never have to guess again.
offsetX vs clientX vs pageX vs screenX: Which One?
Here is the comparison table that should have been on every tutorial page but was not. Each property measures the same cursor position from a different origin point:
| Property | Reference Point | Best For | Changes On Scroll? |
|---|---|---|---|
offsetX / offsetY | The padding edge of the target element (the element under the cursor) | Hover effects, element-local interactions | No |
clientX / clientY | The top-left corner of the browser viewport | Positioning tooltips, dropdowns, modals relative to the visible page | No |
pageX / pageY | The top-left corner of the entire document (including scrolled-off area) | Drawing on canvas, anchoring elements that should stay put when scrolled | Yes |
screenX / screenY | The top-left corner of your physical screen (the monitor) | Multi-window apps, browser extensions, rarely needed in normal web apps | No |
The one that trips people up: pageX includes scroll offset, clientX does not. If your page is 3000px tall and you scroll down 1000px, then pageY will be roughly 1000 more than clientY at the same cursor position. The live demo above proves this -- scroll down and watch the gap.
Rule of thumb: use clientX/clientY for 90% of UI positioning tasks. Reach for offsetX when you only care about the element under the cursor. Use pageX when you need document-anchored coordinates (canvas drawing, absolutely positioned elements). Almost never use screenX in normal web development.
getBoundingClientRect(): The Swiss Army Knife You Need
Here is something tutorials barely cover: the most useful coordinate function in JavaScript is not a mouse event property at all. It is Element.getBoundingClientRect(). It returns the size and position of any element relative to the viewport:
var rect = element.getBoundingClientRect();
// rect.left, rect.top -- distance from viewport top-left
// rect.right, rect.bottom -- opposite edges
// rect.width, rect.height -- element dimensions
// All values include CSS border but NOT margin
The real power: subtract rect.left from e.clientX to get the mouse position relative to that element, regardless of where it sits on the page. This is how you build reliable click targets, canvas drawing, and drag zones:
element.addEventListener('mousemove', function(e) {
var rect = this.getBoundingClientRect();
var localX = e.clientX - rect.left; // X relative to this element
var localY = e.clientY - rect.top; // Y relative to this element
console.log('Mouse inside element:', localX, localY);
});
This is more reliable than offsetX in edge cases. When your element has child elements, offsetX can suddenly jump because the reference shifts to the child. The getBoundingClientRect() approach always gives you coordinates relative to the element you actually care about.
CSS Transforms: Where Coordinates Silently Break
This is the section born from real bug reports. If you apply transform: scale() or transform: rotate() to an element, the coordinate properties start lying to you -- and the documentation barely mentions it.
transform: scale() distorts offsetX
When you scale an element to 50%, the browser shrinks the visual size but offsetX reports coordinates in the scaled coordinate space. If your element is 200px wide and scaled to 0.5, moving the mouse to the visual center gives offsetX = 50, not 100. The getBoundingClientRect() approach also returns scaled dimensions.
// WRONG: offsetX under scale() gives scaled coordinates
// If element is scaled to 0.5, offsetX is halved
element.style.transform = 'scale(0.5)';
element.addEventListener('mousemove', function(e) {
console.log(e.offsetX); // Reports in scaled space
});
// CORRECT: Use getBoundingClientRect for true visual position
element.addEventListener('mousemove', function(e) {
var rect = element.getBoundingClientRect();
var visualX = e.clientX - rect.left;
var visualY = e.clientY - rect.top;
// To get the unscaled coordinate:
var scaleX = element.offsetWidth / rect.width;
var realX = visualX * scaleX;
console.log('Unscaled X:', realX);
});
SVG elements: offsetX behaves differently
In SVG, offsetX/offsetY may reference the SVG root or the specific child shape depending on browser. The reliable approach is getBoundingClientRect() or createSVGPoint():
// SVG: use createSVGPoint for accurate coordinates
var svg = document.querySelector('svg');
var pt = svg.createSVGPoint();
svg.addEventListener('pointermove', function(e) {
pt.x = e.clientX;
pt.y = e.clientY;
// Convert to SVG user space:
var svgPoint = pt.matrixTransform(
svg.getScreenCTM().inverse()
);
console.log('SVG coords:', svgPoint.x, svgPoint.y);
});
Pointer Events: Stop Using mousemove Alone
If you are still writing separate handlers for mousemove, touchmove, and touchstart, you are doing three times the work for worse results. Pointer Events unify mouse, touch, and pen into a single API:
// Old way: three event types, three handlers
element.addEventListener('mousemove', handleMouse);
element.addEventListener('touchmove', handleTouch); // different event shape!
element.addEventListener('touchstart', handleTouchStart);
// Modern way: one event type
element.addEventListener('pointermove', function(e) {
// Same properties as MouseEvent:
console.log(e.clientX, e.clientY, e.pointerType);
// pointerType: 'mouse', 'touch', or 'pen'
});
The key advantage: pointermove fires for mouse, finger, and stylus with the exact same clientX/clientY interface. No more mapping touches[0] to a fake mouse event.
| Event | Mouse | Touch | Pen | Notes |
|---|---|---|---|---|
mousemove | Yes | No (emulated) | No | Legacy, touch emulates mousemove with delay |
touchmove | No | Yes | No | Uses touches array, different API shape |
pointermove | Yes | Yes | Yes | Unified API, same coordinates interface |
Browser support for Pointer Events is now universal (97%+ global). Unless you support very old browsers, pointermove is the right choice. Add touch-action: none CSS to prevent the browser from hijacking touch gestures for scrolling.
Performance: Throttle vs requestAnimationFrame
A mousemove event can fire over 100 times per second on a fast computer. If your handler does DOM updates, layout calculations, or canvas redraws, you will tank performance. Two solutions: throttling and requestAnimationFrame.
Throttling (limit calls per second)
// Throttle: max one call per 16ms (~60fps)
var lastTime = 0;
element.addEventListener('mousemove', function(e) {
var now = Date.now();
if (now - lastTime < 16) return;
lastTime = now;
doExpensiveWork(e.clientX, e.clientY);
});
requestAnimationFrame (sync with browser repaint)
// rAF: batch updates to the next frame
var pendingEvent = null;
element.addEventListener('mousemove', function(e) {
pendingEvent = e; // Store latest, drop intermediates
});
function update() {
if (pendingEvent) {
doExpensiveWork(pendingEvent.clientX, pendingEvent.clientY);
pendingEvent = null;
}
requestAnimationFrame(update);
}
requestAnimationFrame(update);
Which is better? requestAnimationFrame is the superior approach for visual updates because it syncs with the display refresh rate and automatically pauses when the tab is not visible. Throttling is simpler for non-visual tasks like analytics logging. For anything that moves pixels on screen, use rAF.
Measured impact
On a typical page with a canvas redraw in the handler: raw mousemove drops to 12-15fps under heavy mouse movement. Throttled to 60fps: smooth but still does redundant work between frames. With rAF: stays at 60fps with zero wasted renders. The difference is visible in real applications.
Production Code: Drag-and-Drop with Boundary Clamping
Here is the drag-and-drop implementation we wish we had when starting out. It uses pointermove (unified mouse/touch), clamps the element within a container, and cleans up listeners properly:
function makeDraggable(element, container) {
var isDragging = false;
var startX, startY, origLeft, origTop;
element.addEventListener('pointerdown', function(e) {
isDragging = true;
startX = e.clientX;
startY = e.clientY;
var rect = element.getBoundingClientRect();
origLeft = rect.left;
origTop = rect.top;
element.setPointerCapture(e.pointerId);
e.preventDefault();
});
element.addEventListener('pointermove', function(e) {
if (!isDragging) return;
var deltaX = e.clientX - startX;
var deltaY = e.clientY - startY;
var newLeft = origLeft + deltaX;
var newTop = origTop + deltaY;
// Boundary clamping: keep element inside container
var cRect = container.getBoundingClientRect();
var eRect = element.getBoundingClientRect();
var maxLeft = cRect.right - eRect.width;
var maxTop = cRect.bottom - eRect.height;
newLeft = Math.max(cRect.left, Math.min(newLeft, maxLeft));
newTop = Math.max(cRect.top, Math.min(newTop, maxTop));
element.style.left = newLeft + 'px';
element.style.top = newTop + 'px';
});
element.addEventListener('pointerup', function(e) {
isDragging = false;
element.releasePointerCapture(e.pointerId);
});
}
Two things make this production-ready: setPointerCapture() ensures the element keeps receiving events even if the cursor leaves it mid-drag, and the boundary clamping uses getBoundingClientRect() to respect actual rendered sizes rather than guessing from CSS values.
Canvas Coordinate Mapping
Drawing on a canvas requires mapping mouse coordinates to canvas pixel space. When the canvas has CSS sizing different from its internal resolution (which it should, for HiDPI displays), the naive approach breaks:
var canvas = document.getElementById('my-canvas');
var ctx = canvas.getContext('2d');
// Set internal resolution for HiDPI
var dpr = window.devicePixelRatio || 1;
var rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
canvas.addEventListener('pointermove', function(e) {
var rect = canvas.getBoundingClientRect();
// CSS pixel coordinates (what you draw with):
var cssX = e.clientX - rect.left;
var cssY = e.clientY - rect.top;
// If you need raw canvas pixels:
var pixelX = cssX * dpr;
var pixelY = cssY * dpr;
ctx.fillRect(cssX - 2, cssY - 2, 4, 4); // Draw at cursor
});
The dpr (device pixel ratio) scaling is essential on Retina/HiDPI displays. Without it, your canvas drawing appears blurry and coordinate clicks are slightly off. We cover this in depth in our physical vs logical pixels guide.
FAQ: Common Gotchas
Why does offsetX jump when my mouse crosses a child element?
offsetX is relative to whatever element is directly under the cursor. When you move from a parent to a child, the reference point jumps to the child's corner. Use e.clientX - element.getBoundingClientRect().left for stable element-relative coordinates.
Do mouse coordinates work inside an iframe?
Yes, but screenX/screenY report values relative to the iframe's position within the parent page, not the real monitor. clientX and pageX work normally within the iframe's own document. Cross-frame coordinate translation requires postMessage communication.
What happens to coordinates in Shadow DOM?
Mouse events retarget when crossing Shadow DOM boundaries. e.target may point to the shadow host rather than the internal element. event.composedPath() gives you the real event path through shadow boundaries. offsetX/offsetY reference the shadow host, not the internal element.
Are coordinates affected by CSS transforms on parent elements?
Yes. getBoundingClientRect() returns the visually transformed position, so if a parent has transform: rotate(45deg), the rect will reflect the rotated layout. clientX/clientY are always in viewport space and are not affected by transforms, but the element's position relative to those coordinates is.
What is movementX/movementY?
These properties report the delta (change) since the last mousemove event, not an absolute position. They are useful for implementing things like pointer lock (FPS-style camera controls) where you care about how far the mouse moved, not where it is.
Should I use pageX or clientX?
Use clientX for most UI positioning (tooltips, modals, popovers) because it ignores scroll offset. Use pageX when you need to remember a position across scroll changes (like marking a spot on a long document). The difference is only the scroll offset: pageX = clientX + window.scrollX.
Keep Going: Now that you understand the JavaScript side, see these coordinates in action across different contexts:
Mouse Position Detector — Live tool showing all four coordinate systems with click-to-capture
How to Find Pixel Coordinates — Platform methods for Windows, macOS, Linux, and browser
Physical vs Logical Pixels — Why your coordinates differ between CSS and device pixels
Screen Coordinates Guide — Complete reference for screen coordinate concepts