Pointer Events
Unified input handling that works across mouse, touch, and pen -- the modern replacement for separate mouse and touch events.
Search across all documentation pages
Unified input handling that works across mouse, touch, and pen -- the modern replacement for separate mouse and touch events.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
| 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 |
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.
// 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:
setPointerCapture to keep receiving events even when the pointer leaves the canvastouch-none CSS to prevent browser scroll during drawingpointerId that persists for the lifetime of that interaction.pointerType tells 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.preventDefault() on a pointer event, the mouse event is suppressed.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
/>// 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());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 each pointerId in a Map.
setPointerCapture must be called inside onPointerDown -- Calling it in other handlers or after an async operation fails silently or throws. Fix: Always call e.currentTarget.setPointerCapture(e.pointerId) synchronously inside onPointerDown.
Mouse events still fire after pointer events -- Pointer events do not automatically suppress mouse events. Both onPointerDown and onMouseDown will fire for mouse input. Fix: Remove mouse event handlers when using pointer events, or call e.preventDefault() in the pointer handler to suppress the mouse event.
pressure is always 0.5 for mouse buttons -- Mice without pressure sensors report 0.5 when any button is pressed and 0 otherwise. Fix: Check e.pointerType === "pen" before using pressure for pressure-sensitive features.
Touch scrolling is not prevented by pointer handlers alone -- Handling onPointerMove does not stop the browser from scrolling on touch. Fix: Add touch-action: none in CSS to the element, or use e.preventDefault() on the native pointermove via addEventListener with { passive: false }.
onPointerLeave fires during pointer capture -- When you have pointer capture and the pointer moves outside the element, pointerleave still fires even though you're still receiving pointermove. Fix: Use onGotPointerCapture / onLostPointerCapture to track capture state and ignore leave events while captured.
pointerId changes between touch sequences -- Lifting and re-placing a finger gives it a new pointerId. Do not assume the same finger will have the same ID across separate touch sequences. Fix: Clear your pointer tracking state on pointerup / pointercancel.
| 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 |
touch-action: none as a CSS property on the draggable elementpointermove listener with { passive: false } and call preventDefault()setPointerCapture redirects all future events for that pointer to the capturing elementonPointerDown and it auto-releases on pointerupfunction handlePointer(e: React.PointerEvent) {
if (e.pointerType === "mouse") { /* mouse input */ }
if (e.pointerType === "touch") { /* finger touch */ }
if (e.pointerType === "pen") { /* stylus/pen */ }
}pointerleave fires and you stop receiving pointermovesetPointerCapture(e.pointerId) in onPointerDown to keep receiving eventsReviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥