React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

pointermousetouchpendragdrawcanvasreact-19typescript

Pointer Events

Unified input handling that works across mouse, touch, and pen -- the modern replacement for separate mouse and touch events.

Event Reference

EventFires WhenEvent Object
onPointerDownA pointer becomes active (button press, touch, pen contact)React.PointerEvent
onPointerUpA pointer is releasedReact.PointerEvent
onPointerMoveA pointer changes positionReact.PointerEvent
onPointerEnterA pointer enters the element bounds (does not bubble)React.PointerEvent
onPointerLeaveA pointer exits the element bounds (does not bubble)React.PointerEvent
onPointerOverA pointer enters the element bounds (bubbles)React.PointerEvent
onPointerOutA pointer exits the element bounds (bubbles)React.PointerEvent
onPointerCancelThe system cancels the pointer interactionReact.PointerEvent
onGotPointerCaptureThe element receives pointer captureReact.PointerEvent
onLostPointerCaptureThe element loses pointer captureReact.PointerEvent

Key properties on React.PointerEvent:

PropertyTypeDescription
pointerIdnumberUnique identifier for the pointer
pointerType"mouse" | "touch" | "pen"Type of input device
pressurenumberPressure from 0 to 1 (0.5 for mouse buttons with no pressure sensor)
widthnumberContact width in CSS pixels
heightnumberContact height in CSS pixels
tiltXnumberPen tilt along X axis (-90 to 90 degrees)
tiltYnumberPen tilt along Y axis (-90 to 90 degrees)
isPrimarybooleanWhether 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 setPointerCapture to 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-none CSS 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 pointerId 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.
  • 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 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.

Alternatives

AlternativeUse WhenDon't Use When
Mouse Events (onMouseDown, etc.)You only support desktop and need right-click context menu handlingYou need touch/pen support
Touch Events (onTouchStart, etc.)You need multi-touch data (touches, changedTouches)A single pointer is sufficient
HTML Drag and Drop APIYou need native drag-and-drop with file/data transferYou need smooth custom drag animations
Gesture libraries (use-gesture)You need complex gestures with momentum, inertia, constraintsA simple click/drag is enough
CSS :hover / :activeVisual-only hover/press statesYou 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: none as a CSS property on the draggable element
  • This tells the browser not to handle touch gestures on that element
  • Alternatively, attach a native pointermove listener with { passive: false } and call preventDefault()
What is pointer capture and when should I use it?
  • setPointerCapture redirects 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 onPointerDown and it auto-releases on pointerup
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
  • pointerleave fires and you stop receiving pointermove
  • Use setPointerCapture(e.pointerId) in onPointerDown to keep receiving events