Pointer Events
Unified input handling that works across mouse, touch, and pen -- the modern replacement for separate mouse and touch events.
Event Reference
| Event | Fires When | Event Object |
|---|---|---|
onPointerDown | A pointer becomes active (button press, touch, pen contact) | React.PointerEvent |
onPointerUp | A pointer is released | React.PointerEvent |
onPointerMove | A pointer changes position | React.PointerEvent |
onPointerEnter | A pointer enters the element bounds (does not bubble) | React.PointerEvent |
onPointerLeave | A pointer exits the element bounds (does not bubble) | React.PointerEvent |
onPointerOver | A pointer enters the element bounds (bubbles) | React.PointerEvent |
onPointerOut | A pointer exits the element bounds (bubbles) | React.PointerEvent |
onPointerCancel | The system cancels the pointer interaction | React.PointerEvent |
onGotPointerCapture | The element receives pointer capture | React.PointerEvent |
onLostPointerCapture | The element loses pointer capture | React.PointerEvent |
Key properties on React.PointerEvent:
| Property | Type | Description |
|---|---|---|
pointerId | number | Unique identifier for the pointer |
pointerType | "mouse" | "touch" | "pen" | Type of input device |
pressure | number | Pressure from 0 to 1 (0.5 for mouse buttons with no pressure sensor) |
width | number | Contact width in CSS pixels |
height | number | Contact height in CSS pixels |
tiltX | number | Pen tilt along X axis (-90 to 90 degrees) |
tiltY | number | Pen tilt along Y axis (-90 to 90 degrees) |
isPrimary | boolean | Whether this is the primary pointer of its type |
Recipe
Quick-reference recipe card -- copy-paste ready.
// Press state with pointer events
"use client";
import { useState, useCallback } from "react";
export function PressableButton({ children }: { children: React.ReactNode }) {
const [pressed, setPressed] = useState(false);
return (
<button
className={`px-6 py-3 rounded-lg transition-transform ${
pressed ? "scale-95 bg-blue-700" : "scale-100 bg-blue-600"
} text-white`}
onPointerDown={() => setPressed(true)}
onPointerUp={() => setPressed(false)}
onPointerLeave={() => setPressed(false)}
>
{children}
</button>
);
}// Pointer position tracking
"use client";
import { useState, useCallback } from "react";
export function PointerTracker() {
const [pos, setPos] = useState({ x: 0, y: 0 });
const handleMove = useCallback((e: React.PointerEvent) => {
setPos({ x: e.clientX, y: e.clientY });
}, []);
return (
<div className="h-64 bg-gray-100 relative" onPointerMove={handleMove}>
<div
className="w-4 h-4 bg-red-500 rounded-full absolute -translate-x-1/2 -translate-y-1/2 pointer-events-none"
style={{ left: pos.x, top: pos.y }}
/>
</div>
);
}When to reach for this: You want input handling that works identically for mouse, touch, and stylus without writing separate handlers for each.
Working Example
// DrawingCanvas.tsx
"use client";
import { useRef, useState, useCallback } from "react";
type Point = { x: number; y: number; pressure: number };
type Stroke = Point[];
export default function DrawingCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [strokes, setStrokes] = useState<Stroke[]>([]);
const currentStroke = useRef<Stroke>([]);
const getCanvasPoint = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>): Point => {
const rect = e.currentTarget.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
pressure: e.pressure,
};
},
[]
);
const drawStroke = useCallback(
(ctx: CanvasRenderingContext2D, points: Point[]) => {
if (points.length < 2) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
const p = points[i];
ctx.lineWidth = 2 + p.pressure * 6; // pressure-sensitive width
ctx.lineTo(p.x, p.y);
}
ctx.stroke();
},
[]
);
const redrawAll = useCallback(
(allStrokes: Stroke[]) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "#1e293b";
ctx.lineCap = "round";
ctx.lineJoin = "round";
allStrokes.forEach((stroke) => drawStroke(ctx, stroke));
},
[drawStroke]
);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
setIsDrawing(true);
currentStroke.current = [getCanvasPoint(e)];
},
[getCanvasPoint]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const point = getCanvasPoint(e);
currentStroke.current.push(point);
// Draw incrementally for responsiveness
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!ctx) return;
const points = currentStroke.current;
if (points.length < 2) return;
ctx.strokeStyle = "#1e293b";
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.lineWidth = 2 + point.pressure * 6;
ctx.beginPath();
ctx.moveTo(points[points.length - 2].x, points[points.length - 2].y);
ctx.lineTo(point.x, point.y);
ctx.stroke();
},
[isDrawing, getCanvasPoint]
);
const handlePointerUp = useCallback(() => {
if (!isDrawing) return;
setIsDrawing(false);
const finished = [...currentStroke.current];
setStrokes((prev) => [...prev, finished]);
currentStroke.current = [];
}, [isDrawing]);
const handleClear = useCallback(() => {
setStrokes([]);
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (ctx && canvas) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}, []);
const handleUndo = useCallback(() => {
setStrokes((prev) => {
const next = prev.slice(0, -1);
redrawAll(next);
return next;
});
}, [redrawAll]);
return (
<div className="space-y-4">
<div className="flex gap-2">
<button
onClick={handleUndo}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Undo
</button>
<button
onClick={handleClear}
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200"
>
Clear
</button>
<span className="self-center text-sm text-gray-500">
{strokes.length} stroke{strokes.length !== 1 ? "s" : ""}
</span>
</div>
<canvas
ref={canvasRef}
width={600}
height={400}
className="border border-gray-300 rounded-lg cursor-crosshair touch-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
/>
</div>
);
}What this demonstrates:
- Using
setPointerCaptureto keep receiving events even when the pointer leaves the canvas - Pressure-sensitive line width that responds to pen/stylus pressure
- Incremental drawing for real-time responsiveness rather than redrawing every frame
touch-noneCSS to prevent browser scroll during drawing- Undo/clear functionality with stroke history
Deep Dive
How It Works
- Pointer events are a W3C standard that unifies mouse, touch, and pen input into a single event model.
- Each physical input point gets a unique
pointerIdthat persists for the lifetime of that interaction. pointerTypetells you the input device so you can adapt behavior (e.g., show hover preview for mouse, skip for touch).setPointerCapture(pointerId)redirects all events for that pointer to the capturing element, even if the pointer moves outside. This is essential for drag operations.- Pointer events fire before the corresponding mouse events. If you call
preventDefault()on a pointer event, the mouse event is suppressed.
Variations
Pressure-sensitive drawing with pen input:
"use client";
import { useCallback } from "react";
export function PressureLine() {
const handleMove = useCallback(
(e: React.PointerEvent<HTMLCanvasElement>) => {
const canvas = e.currentTarget;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// pressure: 0 = hover, 0-1 = contact pressure
// For mice without pressure, pressure is 0.5 when a button is pressed
const lineWidth = e.pointerType === "pen" ? e.pressure * 10 : 2;
ctx.lineWidth = lineWidth;
ctx.lineCap = "round";
ctx.lineTo(
e.clientX - canvas.getBoundingClientRect().left,
e.clientY - canvas.getBoundingClientRect().top
);
ctx.stroke();
},
[]
);
return (
<canvas
width={600}
height={400}
className="border touch-none"
onPointerMove={handleMove}
/>
);
}Pointer capture for reliable drag:
"use client";
import { useState, useRef, useCallback } from "react";
export function DraggableBox() {
const [position, setPosition] = useState({ x: 100, y: 100 });
const dragOffset = useRef({ x: 0, y: 0 });
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
dragOffset.current = {
x: e.clientX - position.x,
y: e.clientY - position.y,
};
},
[position]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
setPosition({
x: e.clientX - dragOffset.current.x,
y: e.clientY - dragOffset.current.y,
});
},
[]
);
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.currentTarget.releasePointerCapture(e.pointerId);
},
[]
);
return (
<div
className="w-24 h-24 bg-purple-500 rounded-lg cursor-grab active:cursor-grabbing absolute"
style={{ left: position.x, top: position.y }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
/>
);
}Hover detection across device types:
"use client";
import { useState, useCallback } from "react";
export function AdaptiveHover({ children }: { children: React.ReactNode }) {
const [hovered, setHovered] = useState(false);
const [inputType, setInputType] = useState<string>("mouse");
const handleEnter = useCallback((e: React.PointerEvent) => {
setInputType(e.pointerType);
// Only show hover state for mouse/pen -- touch has no hover
if (e.pointerType !== "touch") {
setHovered(true);
}
}, []);
const handleLeave = useCallback(() => {
setHovered(false);
}, []);
return (
<div
className={`p-4 rounded-lg border-2 transition-colors ${
hovered ? "border-blue-500 bg-blue-50" : "border-gray-200"
}`}
onPointerEnter={handleEnter}
onPointerLeave={handleLeave}
>
{children}
<p className="text-xs text-gray-400 mt-2">Input: {inputType}</p>
</div>
);
}Multi-pointer tracking:
"use client";
import { useState, useCallback } from "react";
type PointerInfo = {
id: number;
x: number;
y: number;
type: string;
};
export function MultiPointerTracker() {
const [pointers, setPointers] = useState<Map<number, PointerInfo>>(new Map());
const handlePointerDown = useCallback((e: React.PointerEvent) => {
setPointers((prev) => {
const next = new Map(prev);
next.set(e.pointerId, {
id: e.pointerId,
x: e.clientX,
y: e.clientY,
type: e.pointerType,
});
return next;
});
}, []);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
setPointers((prev) => {
if (!prev.has(e.pointerId)) return prev;
const next = new Map(prev);
next.set(e.pointerId, {
id: e.pointerId,
x: e.clientX,
y: e.clientY,
type: e.pointerType,
});
return next;
});
}, []);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
setPointers((prev) => {
const next = new Map(prev);
next.delete(e.pointerId);
return next;
});
}, []);
return (
<div
className="h-96 bg-gray-50 relative touch-none"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
{Array.from(pointers.values()).map((p) => (
<div
key={p.id}
className="absolute w-8 h-8 rounded-full -translate-x-1/2 -translate-y-1/2 flex items-center justify-center text-white text-xs pointer-events-none"
style={{
left: p.x,
top: p.y,
backgroundColor: p.type === "touch" ? "#ef4444" : "#3b82f6",
}}
>
{p.id}
</div>
))}
<p className="absolute bottom-4 left-4 text-sm text-gray-500">
Active pointers: {pointers.size}
</p>
</div>
);
}Pointer events vs mouse events -- comparison:
// Mouse events -- separate handlers needed for touch vs mouse
<div
onMouseDown={handleMouseDown} // mouse only
onMouseMove={handleMouseMove} // mouse only
onMouseUp={handleMouseUp} // mouse only
onTouchStart={handleTouchStart} // touch only
onTouchMove={handleTouchMove} // touch only
onTouchEnd={handleTouchEnd} // touch only
/>
// Pointer events -- single set of handlers for all input types
<div
onPointerDown={handlePointerDown} // mouse + touch + pen
onPointerMove={handlePointerMove} // mouse + touch + pen
onPointerUp={handlePointerUp} // mouse + touch + pen
/>TypeScript Notes
// The event type includes pointer-specific properties
function handlePointer(e: React.PointerEvent<HTMLDivElement>) {
const id: number = e.pointerId;
const type: string = e.pointerType; // "mouse" | "touch" | "pen"
const pressure: number = e.pressure; // 0 to 1
const isPrimary: boolean = e.isPrimary;
// Inherited from MouseEvent
const x: number = e.clientX;
const y: number = e.clientY;
const button: number = e.button;
}
// Narrowing by pointerType
function handleAdaptive(e: React.PointerEvent) {
switch (e.pointerType) {
case "mouse":
// Has hover, right-click
break;
case "touch":
// No hover, has width/height for contact area
break;
case "pen":
// Has pressure, tilt
const tiltX: number = e.tiltX;
const tiltY: number = e.tiltY;
break;
}
}
// Pointer capture methods on the element
function handleDown(e: React.PointerEvent<HTMLDivElement>) {
const el: HTMLDivElement = e.currentTarget;
el.setPointerCapture(e.pointerId); // returns void
el.hasPointerCapture(e.pointerId); // returns boolean
el.releasePointerCapture(e.pointerId); // returns void
}
// Map for tracking multiple pointers
const pointers = useRef<Map<number, { x: number; y: number }>>(new Map());Gotchas
-
Pointer events do not provide
touches/changedTouches-- Unlike touch events, pointer events fire once per pointer, not once with a list of all touches. Fix: If you need multi-touch details like pinch distance, use touch events or track eachpointerIdin aMap. -
setPointerCapturemust be called insideonPointerDown-- Calling it in other handlers or after an async operation fails silently or throws. Fix: Always calle.currentTarget.setPointerCapture(e.pointerId)synchronously insideonPointerDown. -
Mouse events still fire after pointer events -- Pointer events do not automatically suppress mouse events. Both
onPointerDownandonMouseDownwill fire for mouse input. Fix: Remove mouse event handlers when using pointer events, or calle.preventDefault()in the pointer handler to suppress the mouse event. -
pressureis always 0.5 for mouse buttons -- Mice without pressure sensors report 0.5 when any button is pressed and 0 otherwise. Fix: Checke.pointerType === "pen"before usingpressurefor pressure-sensitive features. -
Touch scrolling is not prevented by pointer handlers alone -- Handling
onPointerMovedoes not stop the browser from scrolling on touch. Fix: Addtouch-action: nonein CSS to the element, or usee.preventDefault()on the nativepointermoveviaaddEventListenerwith{ passive: false }. -
onPointerLeavefires during pointer capture -- When you have pointer capture and the pointer moves outside the element,pointerleavestill fires even though you're still receivingpointermove. Fix: UseonGotPointerCapture/onLostPointerCaptureto track capture state and ignore leave events while captured. -
pointerIdchanges between touch sequences -- Lifting and re-placing a finger gives it a newpointerId. Do not assume the same finger will have the same ID across separate touch sequences. Fix: Clear your pointer tracking state onpointerup/pointercancel.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Mouse Events (onMouseDown, etc.) | You only support desktop and need right-click context menu handling | You need touch/pen support |
Touch Events (onTouchStart, etc.) | You need multi-touch data (touches, changedTouches) | A single pointer is sufficient |
| HTML Drag and Drop API | You need native drag-and-drop with file/data transfer | You need smooth custom drag animations |
Gesture libraries (use-gesture) | You need complex gestures with momentum, inertia, constraints | A simple click/drag is enough |
CSS :hover / :active | Visual-only hover/press states | You need JS logic based on hover/press |
FAQs
Should I use pointer events or mouse events in React 19?
- Pointer events are the recommended approach for new code
- They work across mouse, touch, and pen with a single set of handlers
- Mouse events are still valid but only respond to mouse input
- If you use pointer events, remove any duplicate mouse event handlers
How do I prevent scrolling while dragging with pointer events?
- Add
touch-action: noneas a CSS property on the draggable element - This tells the browser not to handle touch gestures on that element
- Alternatively, attach a native
pointermovelistener with{ passive: false }and callpreventDefault()
What is pointer capture and when should I use it?
setPointerCaptureredirects all future events for that pointer to the capturing element- Even if the pointer leaves the element or the viewport, events still fire on the captured element
- Essential for drag operations where the user may move outside the drag target
- Call it in
onPointerDownand it auto-releases onpointerup
How do I distinguish between mouse, touch, and pen input?
function handlePointer(e: React.PointerEvent) {
if (e.pointerType === "mouse") { /* mouse input */ }
if (e.pointerType === "touch") { /* finger touch */ }
if (e.pointerType === "pen") { /* stylus/pen */ }
}Why does my drag handler lose track of the pointer when moving fast?
- Without pointer capture, moving the pointer fast enough leaves the element bounds
pointerleavefires and you stop receivingpointermove- Use
setPointerCapture(e.pointerId)inonPointerDownto keep receiving events
Related
- Touch Events -- Multi-touch gestures for mobile
- Scroll Events -- Scroll position tracking and infinite scroll
- Media & Animation Events -- Media playback and CSS animation lifecycle