How to Use Pointer Events in JavaScript
Mouse events have been the foundation of interactive web development for decades, but they only handle one input device: the mouse. Modern devices support touch screens, stylus pens, and trackpads, each with unique capabilities like pressure sensitivity and multi-touch. Writing separate code for mouse events, touch events, and pen events quickly becomes a maintenance burden.
Pointer Events solve this problem by providing a single, unified event model that works across all pointing devices. One set of listeners handles mouse clicks, finger taps, and stylus strokes. This guide covers everything you need to know about Pointer Events: how they relate to mouse events, their unique properties, how pointer capture works, and how to handle multi-touch interactions.
Pointer Events vs. Mouse Events (Unified Input Model)
Before Pointer Events existed, developers had to handle different input types separately:
// ❌ The old way: separate handlers for each input type
element.addEventListener('mousedown', handleStart);
element.addEventListener('mousemove', handleMove);
element.addEventListener('mouseup', handleEnd);
element.addEventListener('touchstart', handleStart);
element.addEventListener('touchmove', handleMove);
element.addEventListener('touchend', handleEnd);
This meant duplicated logic, edge cases where both mouse and touch events fire on hybrid devices, and no support for stylus-specific features like pressure.
Pointer Events unify all of this:
// ✅ The modern way: one handler for all input types
element.addEventListener('pointerdown', handleStart);
element.addEventListener('pointermove', handleMove);
element.addEventListener('pointerup', handleEnd);
This single set of listeners responds to mouse clicks, touch taps, and stylus presses identically. You can still differentiate between input types when needed using the pointerType property, but you write the logic once.
Direct Mapping to Mouse Events
Every mouse event has a corresponding pointer event. The mapping is straightforward:
| Mouse Event | Pointer Event | Description |
|---|---|---|
mousedown | pointerdown | Button pressed / finger touches / pen contacts |
mouseup | pointerup | Button released / finger lifts / pen lifts |
mousemove | pointermove | Pointer moves |
mouseenter | pointerenter | Pointer enters element (no bubbling) |
mouseleave | pointerleave | Pointer leaves element (no bubbling) |
mouseover | pointerover | Pointer enters element (bubbles) |
mouseout | pointerout | Pointer leaves element (bubbles) |
| no equivalent | pointercancel | Pointer interaction is aborted |
| no equivalent | gotpointercapture | Element receives pointer capture |
| no equivalent | lostpointercapture | Element loses pointer capture |
Notice that Pointer Events include everything mouse events offer, plus additional events like pointercancel, gotpointercapture, and lostpointercapture that have no mouse event equivalent.
Browser Compatibility and Mouse Event Fallback
Pointer Events are supported in all modern browsers. When you use pointer events, the browser still fires the corresponding mouse events after the pointer events for backward compatibility. The order is:
pointerdownmousedownpointerupmouseupclick
This means that if you switch to pointer events, existing click handlers still work. However, if you handle both pointer events and mouse events on the same element, you will get duplicate handling.
// ❌ Double handling: both pointer and mouse listeners
element.addEventListener('pointerdown', (e) => {
console.log('pointerdown'); // Fires first
});
element.addEventListener('mousedown', (e) => {
console.log('mousedown'); // Fires second: duplicate!
});
To prevent the compatibility mouse events from firing after pointer events, call preventDefault() in your pointer event handler:
// ✅ Prevent duplicate mouse events
element.addEventListener('pointerdown', (e) => {
e.preventDefault(); // Stops mousedown and click from firing
console.log('pointerdown');
});
When migrating from mouse events to pointer events, you can replace mouse with pointer in your event names. The API is intentionally designed to be a drop-in replacement. Add e.preventDefault() if you need to suppress the legacy mouse events.
Pointer Event Types
Pointer Events cover every phase of a pointer interaction. Here is the complete set with practical descriptions:
Primary Interaction Events
pointerdown fires when a pointer becomes active. This means a mouse button is pressed, a finger touches the screen, or a pen contacts the surface.
element.addEventListener('pointerdown', (e) => {
console.log(`Pointer ${e.pointerId} down at (${e.clientX}, ${e.clientY})`);
console.log(`Input type: ${e.pointerType}`); // "mouse", "touch", or "pen"
});
pointerup fires when the pointer is deactivated: button released, finger lifted, or pen lifted.
element.addEventListener('pointerup', (e) => {
console.log(`Pointer ${e.pointerId} up`);
});
pointermove fires when the pointer changes position. For touch and pen, it also fires when pressure, tilt, or contact geometry changes even without movement.
element.addEventListener('pointermove', (e) => {
// This fires frequently: throttle if doing heavy work
updateCursor(e.clientX, e.clientY);
});
Enter and Leave Events
These work identically to their mouse counterparts:
pointerover / pointerout bubble and fire when entering or leaving child elements.
pointerenter / pointerleave do not bubble and ignore transitions between child elements.
element.addEventListener('pointerenter', () => {
element.classList.add('hovered');
});
element.addEventListener('pointerleave', () => {
element.classList.remove('hovered');
});
pointercancel: The Interrupted Interaction
The pointercancel event is unique to Pointer Events and has no mouse event equivalent. It fires when the browser decides to take over the pointer interaction or when the interaction is interrupted. Common triggers include:
- The browser starts its own gesture (scrolling, zooming, swipe navigation)
- The device hardware interrupts (phone call, screen rotation)
- The pointer is used for something else (long-press context menu on mobile)
- The CSS
touch-actionproperty disables the interaction
element.addEventListener('pointercancel', (e) => {
console.log(`Pointer ${e.pointerId} was cancelled`);
// Clean up your drag state, animations, etc.
resetDragState();
});
Always handle pointercancel in your drag and drop or drawing code. On touch devices, the browser frequently cancels pointer interactions to handle its own gestures. If you do not handle pointercancel, your application can end up in a broken state where a drag appears to be still active after the finger has lifted.
Here is a robust pattern that handles cancellation:
let isDragging = false;
element.addEventListener('pointerdown', (e) => {
isDragging = true;
element.setPointerCapture(e.pointerId);
});
element.addEventListener('pointermove', (e) => {
if (!isDragging) return;
moveElement(e.clientX, e.clientY);
});
element.addEventListener('pointerup', (e) => {
isDragging = false;
finishDrag();
});
// Handle interruption the same as a normal end
element.addEventListener('pointercancel', (e) => {
isDragging = false;
cancelDrag(); // Revert to original position, clean up, etc.
});
Pointer Event Properties
Pointer events inherit all properties from mouse events (clientX, clientY, button, buttons, shiftKey, ctrlKey, etc.) and add several new ones that describe the pointer in more detail.
pointerId: Unique Pointer Identifier
Every active pointer gets a unique numeric ID. This is essential for multi-touch: each finger gets its own pointerId, which stays consistent across pointerdown, pointermove, and pointerup for that finger.
element.addEventListener('pointerdown', (e) => {
console.log(`Pointer ID: ${e.pointerId}`);
// Mouse always gets the same ID
// Each finger gets a unique ID
// Pen gets its own ID
});
For a mouse, the pointerId is typically 1 and stays the same. For touch, each finger gets an incrementing ID.
pointerType: Identifying the Input Device
The pointerType property tells you what kind of device generated the event:
element.addEventListener('pointerdown', (e) => {
switch (e.pointerType) {
case 'mouse':
console.log('Mouse input');
break;
case 'touch':
console.log('Touch input');
break;
case 'pen':
console.log('Stylus/pen input');
break;
default:
console.log('Unknown input type');
}
});
This is useful when you want unified handling but with slight differences. For example, showing a tooltip on mouse hover but not on touch:
element.addEventListener('pointerenter', (e) => {
if (e.pointerType === 'mouse') {
showTooltip(); // Only for mouse, not accidental touch
}
});
pressure: How Hard the User Is Pressing
The pressure property is a float between 0 and 1 representing how hard the user is pressing:
canvas.addEventListener('pointermove', (e) => {
if (e.pressure > 0) {
// Scale brush size by pressure
const brushSize = 2 + (e.pressure * 18); // 2px to 20px
drawStroke(e.clientX, e.clientY, brushSize);
}
});
- Mouse:
pressureis0.5when any button is pressed,0when no button is pressed. Mice do not have pressure sensors. - Touch:
pressurevaries based on how hard the finger presses (device dependent). - Pen/Stylus: Full pressure range from
0to1on supported devices.
width and height: Contact Geometry
For touch input, width and height describe the size of the contact area in CSS pixels. A finger tip creates a larger contact area than a stylus point:
element.addEventListener('pointerdown', (e) => {
console.log(`Contact size: ${e.width}px x ${e.height}px`);
// Mouse: width=1, height=1
// Touch: varies, e.g., width=40, height=50
// Pen: small values, e.g., width=2, height=2
});
This can be used to distinguish between a precise tap and a broad press:
element.addEventListener('pointerdown', (e) => {
if (e.width > 30 || e.height > 30) {
// Large contact area: probably a thumb or palm
handleBroadTouch(e);
} else {
// Small contact area: precise tap or stylus
handlePreciseTap(e);
}
});
tiltX, tiltY, and twist: Pen Orientation
For stylus input, these properties describe the pen's orientation:
tiltX: Angle between the pen and the Y-Z plane (-90 to 90 degrees)tiltY: Angle between the pen and the X-Z plane (-90 to 90 degrees)twist: Clockwise rotation of the pen around its own axis (0 to 359 degrees)
canvas.addEventListener('pointermove', (e) => {
if (e.pointerType === 'pen') {
// Use tilt to control brush angle
const angle = Math.atan2(e.tiltY, e.tiltX);
drawAngledBrush(e.clientX, e.clientY, angle, e.pressure);
}
});
isPrimary: Identifying the Primary Pointer
When multiple pointers are active (multi-touch), the isPrimary property identifies the first pointer that was put down. The primary pointer is the one that generates compatibility mouse events.
element.addEventListener('pointerdown', (e) => {
if (e.isPrimary) {
console.log('Primary pointer (generates mouse events too)');
} else {
console.log('Secondary pointer (touch only, no mouse events)');
}
});
Complete Properties Summary
| Property | Type | Mouse | Touch | Pen |
|---|---|---|---|---|
pointerId | number | Constant (e.g., 1) | Unique per finger | Unique |
pointerType | string | "mouse" | "touch" | "pen" |
pressure | float (0-1) | 0 or 0.5 | Variable | Full range |
width | number | 1 | Contact width | Small |
height | number | 1 | Contact height | Small |
tiltX | number | 0 | 0 | -90 to 90 |
tiltY | number | 0 | 0 | -90 to 90 |
twist | number | 0 | 0 | 0 to 359 |
isPrimary | boolean | true | First finger only | true |
Pointer Capture: setPointerCapture() and releasePointerCapture()
Pointer capture is one of the most powerful features of Pointer Events and one that has no clean equivalent in mouse events. When an element captures a pointer, all subsequent events for that pointer are directed to the capturing element, regardless of where the pointer actually moves on the screen.
The Problem Without Capture
In the previous guide on drag and drop, we attached mousemove and mouseup to document to ensure we received events even when the pointer left the dragged element. This works, but it is a workaround:
// Old approach: listen on document
element.addEventListener('mousedown', () => {
document.addEventListener('mousemove', onMove); // Must use document
document.addEventListener('mouseup', onUp); // Must use document
});
This pollutes the document with listeners and requires careful cleanup.
The Solution: Pointer Capture
With pointer capture, you tell the browser: "Send all events for this pointer to this element, no matter where the pointer goes."
element.addEventListener('pointerdown', (e) => {
element.setPointerCapture(e.pointerId);
// Now all pointermove and pointerup events for this pointer
// will fire on 'element', even if the pointer leaves it
});
element.addEventListener('pointermove', (e) => {
// This fires on 'element' even when the pointer is over other elements
moveElement(e.clientX, e.clientY);
});
element.addEventListener('pointerup', (e) => {
// Capture is automatically released on pointerup
finishMove();
});
No need to add listeners to document. No need to remove them manually. Pointer capture is automatically released when pointerup or pointercancel fires.
Drag and Drop with Pointer Capture
Here is a clean drag implementation using pointer capture:
<style>
.box {
width: 100px;
height: 100px;
background: #3498db;
position: absolute;
left: 100px;
top: 100px;
border-radius: 10px;
cursor: grab;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
user-select: none;
touch-action: none; /* Important for touch! */
}
.box.dragging {
cursor: grabbing;
opacity: 0.85;
box-shadow: 0 6px 20px rgba(0,0,0,0.3);
}
</style>
<div class="box" id="box">Drag</div>
<script>
const box = document.getElementById('box');
box.addEventListener('pointerdown', (e) => {
e.preventDefault();
const shiftX = e.clientX - box.getBoundingClientRect().left;
const shiftY = e.clientY - box.getBoundingClientRect().top;
box.classList.add('dragging');
box.setPointerCapture(e.pointerId); // Capture the pointer
box.addEventListener('pointermove', onMove);
box.addEventListener('pointerup', onUp);
function onMove(e) {
box.style.left = e.pageX - shiftX + 'px';
box.style.top = e.pageY - shiftY + 'px';
}
function onUp(e) {
box.classList.remove('dragging');
box.removeEventListener('pointermove', onMove);
box.removeEventListener('pointerup', onUp);
// Capture is automatically released, but explicit release is fine too:
// box.releasePointerCapture(e.pointerId);
}
});
</script>
Compare this with the mouse event version from the drag and drop guide:
// Mouse events: must use document
element.addEventListener('mousedown', (e) => {
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
// Pointer events with capture: everything on the element itself
element.addEventListener('pointerdown', (e) => {
element.setPointerCapture(e.pointerId);
element.addEventListener('pointermove', onMove);
element.addEventListener('pointerup', onUp);
});
The pointer capture version is cleaner, more self-contained, and works across all input types.
Capture Events: gotpointercapture and lostpointercapture
These events fire on the element when it gains or loses pointer capture:
element.addEventListener('gotpointercapture', (e) => {
console.log(`Element captured pointer ${e.pointerId}`);
});
element.addEventListener('lostpointercapture', (e) => {
console.log(`Element lost capture of pointer ${e.pointerId}`);
// Good place to clean up drag state
cleanupDrag();
});
Using lostpointercapture for cleanup is often cleaner than putting cleanup code in both pointerup and pointercancel:
const box = document.getElementById('box');
let isDragging = false;
box.addEventListener('pointerdown', (e) => {
isDragging = true;
box.setPointerCapture(e.pointerId);
});
box.addEventListener('pointermove', (e) => {
if (!isDragging) return;
box.style.left = e.pageX + 'px';
box.style.top = e.pageY + 'px';
});
// Single cleanup point: fires for pointerup AND pointercancel
box.addEventListener('lostpointercapture', (e) => {
isDragging = false;
box.classList.remove('dragging');
});
releasePointerCapture()
While capture is automatically released on pointerup and pointercancel, you can release it manually at any time:
element.addEventListener('pointermove', (e) => {
// Release capture if the pointer moves too far (cancel the drag)
const distance = Math.sqrt(
(e.clientX - startX) ** 2 + (e.clientY - startY) ** 2
);
if (distance > 300) {
element.releasePointerCapture(e.pointerId);
// lostpointercapture will fire and handle cleanup
}
});
The touch-action CSS Property
When using Pointer Events on touch devices, the browser still tries to handle its own touch gestures (scrolling, pinching, swiping). These browser gestures trigger pointercancel, which interrupts your custom interactions.
The touch-action CSS property tells the browser which touch gestures to handle natively and which to leave to your JavaScript:
/* Disable ALL browser touch actions: you handle everything */
.draggable {
touch-action: none;
}
/* Allow vertical scrolling, but handle horizontal yourself */
.horizontal-slider {
touch-action: pan-y;
}
/* Allow pinch-zoom but handle panning yourself */
.pan-area {
touch-action: pinch-zoom;
}
Common values:
| Value | Browser handles | You handle |
|---|---|---|
auto | Everything | Nothing (default) |
none | Nothing | Everything |
pan-x | Horizontal scroll | Vertical, zoom |
pan-y | Vertical scroll | Horizontal, zoom |
pan-x pan-y | Both scrolls | Zoom |
pinch-zoom | Pinch zoom | Scrolling |
manipulation | Scroll + pinch zoom | Double-tap zoom |
If your element uses pointer events for dragging or drawing on touch devices, you must set touch-action: none on it. Without this, the browser will cancel your pointer events to start scrolling or zooming, causing pointercancel to fire and breaking your interaction.
<!-- ❌ Drag breaks on touch: browser cancels pointer for scrolling -->
<div class="draggable" style="position: absolute;">Drag me</div>
<!-- ✅ Drag works on touch: browser won't interfere -->
<div class="draggable" style="position: absolute; touch-action: none;">Drag me</div>
Multi-Touch Support
Multi-touch is where Pointer Events truly shine compared to mouse events. Each finger on the screen generates its own stream of pointer events with a unique pointerId. You can track multiple fingers independently.
Tracking Multiple Pointers
const activePointers = new Map();
element.addEventListener('pointerdown', (e) => {
activePointers.set(e.pointerId, {
startX: e.clientX,
startY: e.clientY,
currentX: e.clientX,
currentY: e.clientY
});
element.setPointerCapture(e.pointerId);
console.log(`Active pointers: ${activePointers.size}`);
});
element.addEventListener('pointermove', (e) => {
const pointer = activePointers.get(e.pointerId);
if (!pointer) return;
pointer.currentX = e.clientX;
pointer.currentY = e.clientY;
});
element.addEventListener('pointerup', (e) => {
activePointers.delete(e.pointerId);
console.log(`Active pointers: ${activePointers.size}`);
});
element.addEventListener('pointercancel', (e) => {
activePointers.delete(e.pointerId);
});
Multi-Touch Drawing Canvas
Here is a practical example: a drawing canvas where each finger draws in a different color.
<style>
#canvas {
width: 100%;
height: 400px;
border: 2px solid #333;
border-radius: 8px;
touch-action: none;
cursor: crosshair;
}
</style>
<canvas id="canvas" width="800" height="400"></canvas>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const pointers = new Map();
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6'];
let colorIndex = 0;
canvas.addEventListener('pointerdown', (e) => {
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
const color = colors[colorIndex % colors.length];
colorIndex++;
pointers.set(e.pointerId, {
lastX: e.offsetX,
lastY: e.offsetY,
color: color
});
});
canvas.addEventListener('pointermove', (e) => {
const pointer = pointers.get(e.pointerId);
if (!pointer) return;
ctx.beginPath();
ctx.moveTo(pointer.lastX, pointer.lastY);
ctx.lineTo(e.offsetX, e.offsetY);
ctx.strokeStyle = pointer.color;
ctx.lineWidth = 2 + (e.pressure * 8); // Pressure-sensitive width
ctx.lineCap = 'round';
ctx.stroke();
pointer.lastX = e.offsetX;
pointer.lastY = e.offsetY;
});
canvas.addEventListener('pointerup', (e) => {
pointers.delete(e.pointerId);
});
canvas.addEventListener('pointercancel', (e) => {
pointers.delete(e.pointerId);
});
</script>
Each finger gets its own color and draws an independent stroke. Pressure sensitivity controls the line thickness on supported devices.
Pinch-to-Zoom Gesture
A common multi-touch gesture is pinch-to-zoom. You track two fingers and measure the distance between them:
const pinchArea = document.getElementById('pinch-target');
let initialDistance = null;
let currentScale = 1;
pinchArea.style.touchAction = 'none';
pinchArea.addEventListener('pointerdown', (e) => {
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
pinchArea.setPointerCapture(e.pointerId);
});
pinchArea.addEventListener('pointermove', (e) => {
if (!activePointers.has(e.pointerId)) return;
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (activePointers.size === 2) {
const points = [...activePointers.values()];
const distance = Math.sqrt(
(points[0].x - points[1].x) ** 2 +
(points[0].y - points[1].y) ** 2
);
if (initialDistance === null) {
initialDistance = distance;
} else {
const scale = distance / initialDistance;
pinchArea.style.transform = `scale(${currentScale * scale})`;
}
}
});
function endPointer(e) {
if (activePointers.size === 2 && initialDistance !== null) {
const points = [...activePointers.values()];
const distance = Math.sqrt(
(points[0].x - points[1].x) ** 2 +
(points[0].y - points[1].y) ** 2
);
currentScale *= distance / initialDistance;
}
activePointers.delete(e.pointerId);
if (activePointers.size < 2) {
initialDistance = null;
}
}
pinchArea.addEventListener('pointerup', endPointer);
pinchArea.addEventListener('pointercancel', endPointer);
isPrimary and Compatibility Mouse Events
Only the primary pointer generates compatibility mouse events. On a touch device, this is the first finger that touches the screen. Subsequent fingers are non-primary and do not trigger mousedown, mousemove, or mouseup.
element.addEventListener('pointerdown', (e) => {
if (e.isPrimary) {
// First finger: also triggers mousedown after this
console.log('Primary pointer, mouse compat events will fire');
} else {
// Second, third finger: no mouse events
console.log('Secondary pointer, mouse-only code won't see this');
}
});
This is why code that only uses mouse events only responds to the first finger on touch devices.
Migrating from Mouse Events to Pointer Events
The migration path is straightforward for most code:
Step 1: Replace Event Names
// Before
element.addEventListener('mousedown', handler);
element.addEventListener('mousemove', handler);
element.addEventListener('mouseup', handler);
// After
element.addEventListener('pointerdown', handler);
element.addEventListener('pointermove', handler);
element.addEventListener('pointerup', handler);
Step 2: Add pointercancel Handling
// Add this wherever you have pointerup handling
element.addEventListener('pointercancel', cleanupHandler);
Step 3: Replace document Listeners with Pointer Capture
// Before: listeners on document
element.addEventListener('mousedown', (e) => {
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
// After: pointer capture on the element
element.addEventListener('pointerdown', (e) => {
element.setPointerCapture(e.pointerId);
});
element.addEventListener('pointermove', onMove);
element.addEventListener('lostpointercapture', onUp);
Step 4: Add touch-action CSS
.interactive-element {
touch-action: none; /* For draggables, drawing canvases */
}
/* or */
.scrollable-but-custom-horizontal {
touch-action: pan-y; /* Allow vertical scroll, handle horizontal */
}
Step 5: Handle pointerType for Device-Specific Behavior
element.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'touch') {
// Maybe show larger touch targets
// Maybe skip hover-only UI
}
});
You do not need to remove click event listeners when migrating to pointer events. The click event still fires after pointerup and remains the correct event for button-like interactions. Use pointer events for drag, draw, and gesture interactions where you need continuous tracking.
Summary
Pointer Events provide a unified input model that replaces the need for separate mouse and touch event handling:
- Every mouse event has a pointer equivalent (
mousedownbecomespointerdown, etc.), making migration straightforward. pointerType("mouse","touch","pen") lets you differentiate input devices when needed.pointerIduniquely identifies each active pointer, enabling multi-touch tracking.pressure,width,height,tiltX,tiltYprovide rich input data for creative applications.setPointerCapture()eliminates the need to attach listeners todocumentduring drag operations. All events for a captured pointer flow to the capturing element.pointercancelmust always be handled to clean up interrupted interactions, especially on touch devices.touch-action: nonein CSS is required on elements where you want to handle touch interactions yourself without browser interference.
For any new interactive feature, start with Pointer Events. They work everywhere, handle all input types, and provide a cleaner API than the combination of mouse and touch events they replace.