Skip to main content

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 EventPointer EventDescription
mousedownpointerdownButton pressed / finger touches / pen contacts
mouseuppointerupButton released / finger lifts / pen lifts
mousemovepointermovePointer moves
mouseenterpointerenterPointer enters element (no bubbling)
mouseleavepointerleavePointer leaves element (no bubbling)
mouseoverpointeroverPointer enters element (bubbles)
mouseoutpointeroutPointer leaves element (bubbles)
no equivalentpointercancelPointer interaction is aborted
no equivalentgotpointercaptureElement receives pointer capture
no equivalentlostpointercaptureElement 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:

  1. pointerdown
  2. mousedown
  3. pointerup
  4. mouseup
  5. click

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');
});
tip

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-action property disables the interaction
element.addEventListener('pointercancel', (e) => {
console.log(`Pointer ${e.pointerId} was cancelled`);
// Clean up your drag state, animations, etc.
resetDragState();
});
warning

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: pressure is 0.5 when any button is pressed, 0 when no button is pressed. Mice do not have pressure sensors.
  • Touch: pressure varies based on how hard the finger presses (device dependent).
  • Pen/Stylus: Full pressure range from 0 to 1 on 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

PropertyTypeMouseTouchPen
pointerIdnumberConstant (e.g., 1)Unique per fingerUnique
pointerTypestring"mouse""touch""pen"
pressurefloat (0-1)0 or 0.5VariableFull range
widthnumber1Contact widthSmall
heightnumber1Contact heightSmall
tiltXnumber00-90 to 90
tiltYnumber00-90 to 90
twistnumber000 to 359
isPrimarybooleantrueFirst finger onlytrue

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:

ValueBrowser handlesYou handle
autoEverythingNothing (default)
noneNothingEverything
pan-xHorizontal scrollVertical, zoom
pan-yVertical scrollHorizontal, zoom
pan-x pan-yBoth scrollsZoom
pinch-zoomPinch zoomScrolling
manipulationScroll + pinch zoomDouble-tap zoom
warning

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
}
});
note

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 (mousedown becomes pointerdown, etc.), making migration straightforward.
  • pointerType ("mouse", "touch", "pen") lets you differentiate input devices when needed.
  • pointerId uniquely identifies each active pointer, enabling multi-touch tracking.
  • pressure, width, height, tiltX, tiltY provide rich input data for creative applications.
  • setPointerCapture() eliminates the need to attach listeners to document during drag operations. All events for a captured pointer flow to the capturing element.
  • pointercancel must always be handled to clean up interrupted interactions, especially on touch devices.
  • touch-action: none in 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.