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.
Referencia de Eventos
| 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 |
Receta
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.
Ejemplo Funcional
// 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:
- Usar
setPointerCapturepara seguir recibiendo eventos incluso cuando el puntero sale del lienzo - Ancho de línea sensible a la presión que responde a la presión del lápiz/estilete
- Dibujo incremental para mayor capacidad de respuesta en tiempo real en lugar de redibujar cada fotograma
touch-noneCSS para evitar desplazamiento del navegador durante el dibujo- Funcionalidad deshacer/limpiar con historial de trazos
Profundización
Cómo Funciona
- Los eventos de puntero son un estándar W3C que unifica entrada de ratón, tacto y lápiz en un único modelo de eventos.
- Cada punto de entrada física obtiene un
pointerIdúnico que persiste durante toda la interacción. pointerTypete 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.- Los eventos de puntero se disparan antes de los eventos de ratón correspondientes. Si llamas a
preventDefault()en un evento de puntero, se suprime el evento de ratón.
Variaciones
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
/>Notas de TypeScript
// 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());Problemas Comunes
-
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 cadapointerIden unMap. -
setPointerCapturedebe llamarse dentro deonPointerDown-- Llamarlo en otros manejadores u después de una operación asincrónica falla silenciosamente o lanza. Solución: Siempre llama ae.currentTarget.setPointerCapture(e.pointerId)sincrónicamente dentro deonPointerDown. -
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
onPointerDowncomoonMouseDownse dispararán para entrada de ratón. Solución: Elimina los manejadores de eventos de ratón cuando uses eventos de puntero, o llama ae.preventDefault()en el manejador de puntero para suprimir el evento de ratón. -
pressuresiempre 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: Compruebae.pointerType === "pen"antes de usarpressurepara características sensibles a presión. -
El desplazamiento táctil no se previene solo con manejadores de puntero -- Manejar
onPointerMoveno impide que el navegador se desplace en tacto. Solución: Agregatouch-action: noneen CSS al elemento, o usae.preventDefault()en elpointermovenativo medianteaddEventListenercon{ passive: false }. -
onPointerLeavese dispara durante la captura de puntero -- Cuando tienes captura de puntero y el puntero se mueve fuera del elemento,pointerleavese dispara aunque sigas recibiendopointermove. Solución: UsaonGotPointerCapture/onLostPointerCapturepara rastrear el estado de captura e ignora eventos de salida mientras se captura. -
pointerIdcambia entre secuencias de tacto -- Levantar y volver a colocar un dedo le da un nuevopointerId. No asumas que el mismo dedo tendrá el mismo ID en diferentes secuencias de tacto. Solución: Limpia tu estado de seguimiento de puntero enpointerup/pointercancel.
Alternativas
| 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 |
Preguntas Frecuentes
¿Debo usar eventos de puntero o eventos de ratón en React 19?
- Los eventos de puntero son el enfoque recomendado para código nuevo
- Funcionan en ratón, tacto y lápiz con un único conjunto de manejadores
- Los eventos de ratón aún son válidos pero solo responden a entrada de ratón
- Si usas eventos de puntero, elimina cualquier manejador de eventos de ratón duplicado
¿Cómo evito el desplazamiento mientras arrastro con eventos de puntero?
- Agrega
touch-action: nonecomo propiedad CSS en el elemento arrastradera - Esto le dice al navegador que no maneje gestos de tacto en ese elemento
- Alternativamente, adjunta un manejador
pointermovenativo con{ passive: false }y llama apreventDefault()
¿Qué es la captura de puntero y cuándo debo usarla?
setPointerCaptureredirige todos los eventos futuros para ese puntero al elemento capturador- Incluso si el puntero sale del elemento o la ventana gráfica, los eventos aún se disparan en el elemento capturado
- Esencial para operaciones de arrastre donde el usuario puede moverse fuera del objetivo de arrastre
- Llámalo en
onPointerDowny se libera automáticamente enpointerup
¿Cómo distingo entre entrada de ratón, tacto y lápiz?
function 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 */ }
}¿Por qué mi manejador de arrastre pierde la pista del puntero cuando se mueve rápido?
- Sin captura de puntero, mover el puntero lo suficientemente rápido sale de los límites del elemento
pointerleavese dispara y dejas de recibirpointermove- Usa
setPointerCapture(e.pointerId)enonPointerDownpara seguir recibiendo eventos
Relacionado
- Eventos de Tacto -- Gestos de multi-tacto para móvil
- Eventos de Desplazamiento -- Seguimiento de posición de desplazamiento e desplazamiento infinito
- Eventos de Medios y Animación -- Ciclo de vida de reproducción de medios y animación CSS