Eventos de Puntero
Manejo de entrada unificado que funciona entre ratón, tacto y lápiz -- el reemplazo moderno para eventos de ratón y tacto separados.
Busca en todas las páginas de la documentación
Manejo de entrada unificado que funciona entre ratón, tacto y lápiz -- el reemplazo moderno para eventos de ratón y tacto separados.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
| Evento | Se Dispara Cuando | Objeto de Evento |
|---|---|---|
onPointerDown | Un puntero se activa (presión de botón, tacto, contacto con lápiz) | React.PointerEvent |
onPointerUp | Un puntero se libera | React.PointerEvent |
onPointerMove | Un puntero cambia de posición | React.PointerEvent |
onPointerEnter | Un puntero entra en los límites del elemento (no propaga) | React.PointerEvent |
onPointerLeave | Un puntero sale de los límites del elemento (no propaga) | React.PointerEvent |
onPointerOver | Un puntero entra en los límites del elemento (propaga) | React.PointerEvent |
onPointerOut | Un puntero sale de los límites del elemento (propaga) | React.PointerEvent |
onPointerCancel | El sistema cancela la interacción del puntero | React.PointerEvent |
onGotPointerCapture | El elemento recibe captura de puntero | React.PointerEvent |
onLostPointerCapture | El elemento pierde captura de puntero | React.PointerEvent |
Propiedades clave en React.PointerEvent:
| Propiedad | Tipo | Descripción |
|---|---|---|
pointerId | number | Identificador único para el puntero |
pointerType | "mouse" | "touch" | "pen" | Tipo de dispositivo de entrada |
pressure | number | Presión de 0 a 1 (0.5 para botones de ratón sin sensor de presión) |
width | number | Ancho de contacto en píxeles CSS |
height | number | Alto de contacto en píxeles CSS |
tiltX | number | Inclinación del lápiz en eje X (-90 a 90 grados) |
tiltY | number | Inclinación del lápiz en eje Y (-90 a 90 grados) |
isPrimary | boolean | Si este es el puntero principal de su tipo |
Tarjeta de referencia rápida -- lista para copiar y pegar.
// Presionar estado con eventos de puntero
"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>
);
}// Seguimiento de posición del puntero
"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>
);
}Cuándo usarlo: Quieres manejo de entrada que funcione idénticamente para ratón, tacto y lápiz óptico sin escribir manejadores separados para cada uno.
// 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; // ancho sensible a la presión
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);
// Dibuja incrementalmente para mayor capacidad de respuesta
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"
>
Deshacer
</button>
<button
onClick={handleClear}
className="px-4 py-2 bg-red-100 text-red-700 rounded hover:bg-red-200"
>
Limpiar
</button>
<span className="self-center text-sm text-gray-500">
{strokes.length} trazo{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>
);
}Qué demuestra esto:
setPointerCapture para seguir recibiendo eventos incluso cuando el puntero sale del lienzotouch-none CSS para evitar desplazamiento del navegador durante el dibujopointerId único que persiste durante toda la interacción.pointerType te dice el dispositivo de entrada para que puedas adaptar el comportamiento (p. ej., mostrar vista previa de desplazamiento para ratón, omitir para tacto).setPointerCapture(pointerId) redirige todos los eventos para ese puntero al elemento capturador, incluso si el puntero se mueve fuera. Esto es esencial para operaciones de arrastre.preventDefault() en un evento de puntero, se suprime el evento de ratón.Dibujo sensible a presión con entrada de lápiz:
"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;
// presión: 0 = desplazamiento, 0-1 = presión de contacto
// Para ratones sin presión, la presión es 0.5 cuando se presiona un botón
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}
/>
);
}Captura de puntero para arrastre confiable:
"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}
/>
);
}Detección de desplazamiento entre tipos de dispositivos:
"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);
// Solo mostrar estado de desplazamiento para ratón/lápiz -- tacto no tiene desplazamiento
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">Entrada: {inputType}</p>
</div>
);
}Seguimiento de múltiples punteros:
"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">
Punteros activos: {pointers.size}
</p>
</div>
);
}Eventos de puntero vs eventos de ratón -- comparación:
// Eventos de ratón -- necesita manejadores separados para tacto vs ratón
<div
onMouseDown={handleMouseDown} // solo ratón
onMouseMove={handleMouseMove} // solo ratón
onMouseUp={handleMouseUp} // solo ratón
onTouchStart={handleTouchStart} // solo tacto
onTouchMove={handleTouchMove} // solo tacto
onTouchEnd={handleTouchEnd} // solo tacto
/>
// Eventos de puntero -- conjunto único de manejadores para todos los tipos de entrada
<div
onPointerDown={handlePointerDown} // ratón + tacto + lápiz
onPointerMove={handlePointerMove} // ratón + tacto + lápiz
onPointerUp={handlePointerUp} // ratón + tacto + lápiz
/>// El tipo de evento incluye propiedades específicas del puntero
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 a 1
const isPrimary: boolean = e.isPrimary;
// Heredado de MouseEvent
const x: number = e.clientX;
const y: number = e.clientY;
const button: number = e.button;
}
// Estrechamiento por pointerType
function handleAdaptive(e: React.PointerEvent) {
switch (e.pointerType) {
case "mouse":
// Tiene desplazamiento, clic derecho
break;
case "touch":
// Sin desplazamiento, tiene width/height para área de contacto
break;
case "pen":
// Tiene presión, inclinación
const tiltX: number = e.tiltX;
const tiltY: number = e.tiltY;
break;
}
}
// Métodos de captura de puntero en el elemento
function handleDown(e: React.PointerEvent<HTMLDivElement>) {
const el: HTMLDivElement = e.currentTarget;
el.setPointerCapture(e.pointerId); // devuelve void
el.hasPointerCapture(e.pointerId); // devuelve boolean
el.releasePointerCapture(e.pointerId); // devuelve void
}
// Map para seguimiento de múltiples punteros
const pointers = useRef<Map<number, { x: number; y: number }>>(new Map());Los eventos de puntero no proporcionan touches / changedTouches -- A diferencia de los eventos de tacto, los eventos de puntero se disparan una vez por puntero, no una vez con una lista de todos los toques. Solución: Si necesitas detalles de multi-tacto como distancia de pellizco, usa eventos de tacto o rastrear cada pointerId en un Map.
setPointerCapture debe llamarse dentro de onPointerDown -- Llamarlo en otros manejadores u después de una operación asincrónica falla silenciosamente o lanza. Solución: Siempre llama a e.currentTarget.setPointerCapture(e.pointerId) sincrónicamente dentro de onPointerDown.
Los eventos del ratón aún se disparan después de los eventos de puntero -- Los eventos de puntero no suprimen automáticamente los eventos de ratón. Tanto onPointerDown como onMouseDown se dispararán para entrada de ratón. Solución: Elimina los manejadores de eventos de ratón cuando uses eventos de puntero, o llama a e.preventDefault() en el manejador de puntero para suprimir el evento de ratón.
pressure siempre es 0.5 para botones de ratón -- Los ratones sin sensores de presión reportan 0.5 cuando se presiona cualquier botón y 0 en caso contrario. Solución: Comprueba e.pointerType === "pen" antes de usar pressure para características sensibles a presión.
El desplazamiento táctil no se previene solo con manejadores de puntero -- Manejar onPointerMove no impide que el navegador se desplace en tacto. Solución: Agrega touch-action: none en CSS al elemento, o usa e.preventDefault() en el pointermove nativo mediante addEventListener con { passive: false }.
onPointerLeave se dispara durante la captura de puntero -- Cuando tienes captura de puntero y el puntero se mueve fuera del elemento, pointerleave se dispara aunque sigas recibiendo pointermove. Solución: Usa onGotPointerCapture / onLostPointerCapture para rastrear el estado de captura e ignora eventos de salida mientras se captura.
pointerId cambia entre secuencias de tacto -- Levantar y volver a colocar un dedo le da un nuevo pointerId. No asumas que el mismo dedo tendrá el mismo ID en diferentes secuencias de tacto. Solución: Limpia tu estado de seguimiento de puntero en pointerup / pointercancel.
| Alternativa | Usar Cuando | No Usar Cuando |
|---|---|---|
Eventos del Ratón (onMouseDown, etc.) | Solo soportas escritorio y necesitas manejo de menú de contexto de clic derecho | Necesitas soporte para tacto/lápiz |
Eventos de Tacto (onTouchStart, etc.) | Necesitas datos de multi-tacto (touches, changedTouches) | Un único puntero es suficiente |
| API de Arrastrar y Soltar HTML | Necesitas arrastrar y soltar nativo con transferencia de archivo/datos | Necesitas animaciones de arrastre personalizadas suave |
Bibliotecas de Gestos (use-gesture) | Necesitas gestos complejos con impulso, inercia, restricciones | Un clic/arrastre simple es suficiente |
CSS :hover / :active | Estados visuales solo para desplazamiento/presión | Necesitas lógica JS basada en desplazamiento/presión |
touch-action: none como propiedad CSS en el elemento arrastraderapointermove nativo con { passive: false } y llama a preventDefault()setPointerCapture redirige todos los eventos futuros para ese puntero al elemento capturadoronPointerDown y se libera automáticamente en pointerupfunction handlePointer(e: React.PointerEvent) {
if (e.pointerType === "mouse") { /* entrada de ratón */ }
if (e.pointerType === "touch") { /* tacto con dedo */ }
if (e.pointerType === "pen") { /* estilete/lápiz */ }
}pointerleave se dispara y dejas de recibir pointermovesetPointerCapture(e.pointerId) en onPointerDown para seguir recibiendo eventosRevisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥