Eventos de Tacto
Maneja interacciones de tacto en dispositivos móviles -- toques, gestos de deslizamiento, gestos de pinza y tacto multi-toque.
Busca en todas las páginas de la documentación
Maneja interacciones de tacto en dispositivos móviles -- toques, gestos de deslizamiento, gestos de pinza y tacto multi-toque.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
| Evento | Se Dispara Cuando | Objeto de Evento |
|---|---|---|
onTouchStart | Un dedo toca la pantalla | React.TouchEvent |
onTouchMove | Un dedo se mueve mientras toca la pantalla | React.TouchEvent |
onTouchEnd | Un dedo se levanta de la pantalla | React.TouchEvent |
onTouchCancel | El sistema cancela el toque (p. ej., llamada entrante, conflicto de gesto) | React.TouchEvent |
Cada React.TouchEvent expone:
| Propiedad | Tipo | Descripción |
|---|---|---|
touches | TouchList | Todos los dedos actualmente en la pantalla |
targetTouches | TouchList | Dedos en el elemento objetivo actual |
changedTouches | TouchList | Dedos implicados en el evento actual |
Cada objeto Touch contiene: identifier, clientX, clientY, screenX, screenY, pageX, pageY, target.
Tarjeta de referencia rápida -- lista para copiar y pegar.
// SwipeHandler.tsx -- detección básica de deslizamiento
"use client";
import { useRef, useCallback } from "react";
export function useSwipe(onSwipe: (direction: "left" | "right" | "up" | "down") => void) {
const startX = useRef(0);
const startY = useRef(0);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
startX.current = e.changedTouches[0].clientX;
startY.current = e.changedTouches[0].clientY;
}, []);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
const dx = e.changedTouches[0].clientX - startX.current;
const dy = e.changedTouches[0].clientY - startY.current;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
const threshold = 50;
if (Math.max(absDx, absDy) < threshold) return;
if (absDx > absDy) {
onSwipe(dx > 0 ? "right" : "left");
} else {
onSwipe(dy > 0 ? "down" : "up");
}
},
[onSwipe]
);
return { onTouchStart: handleTouchStart, onTouchEnd: handleTouchEnd };
}Cuándo usarlo: Necesitas detectar gestos de deslizamiento en dispositivos móviles y no puedes confiar en CSS scroll-snap o una biblioteca de gestos.
// SwipeableCards.tsx
"use client";
import { useState, useCallback } from "react";
type Card = { id: number; title: string; color: string };
const cards: Card[] = [
{ id: 1, title: "Welcome", color: "#3b82f6" },
{ id: 2, title: "Features", color: "#10b981" },
{ id: 3, title: "Pricing", color: "#f59e0b" },
{ id: 4, title: "Get Started", color: "#ef4444" },
];
export default function SwipeableCards() {
const [currentIndex, setCurrentIndex] = useState(0);
const [touchStart, setTouchStart] = useState<number | null>(null);
const [touchDelta, setTouchDelta] = useState(0);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
setTouchStart(e.changedTouches[0].clientX);
setTouchDelta(0);
}, []);
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
if (touchStart === null) return;
const current = e.changedTouches[0].clientX;
setTouchDelta(current - touchStart);
},
[touchStart]
);
const handleTouchEnd = useCallback(() => {
const threshold = 80;
if (touchDelta < -threshold && currentIndex < cards.length - 1) {
setCurrentIndex((prev) => prev + 1);
} else if (touchDelta > threshold && currentIndex > 0) {
setCurrentIndex((prev) => prev - 1);
}
setTouchStart(null);
setTouchDelta(0);
}, [touchDelta, currentIndex]);
return (
<div className="overflow-hidden w-full max-w-sm mx-auto">
<div
className="flex transition-transform duration-300 ease-out"
style={{
transform: `translateX(calc(-${currentIndex * 100}% + ${touchStart !== null ? touchDelta : 0}px))`,
transition: touchStart !== null ? "none" : "transform 0.3s ease-out",
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{cards.map((card) => (
<div
key={card.id}
className="min-w-full p-8 rounded-xl text-white text-center text-2xl font-bold"
style={{ backgroundColor: card.color }}
>
{card.title}
</div>
))}
</div>
<div className="flex justify-center gap-2 mt-4">
{cards.map((_, i) => (
<div
key={i}
className={`w-2 h-2 rounded-full ${i === currentIndex ? "bg-gray-800" : "bg-gray-300"}`}
/>
))}
</div>
</div>
);
}Lo que esto demuestra:
onTouchStart, onTouchMove y onTouchEnd juntos para navegar con deslizamientotouchstart -> touchmove (repetido) -> touchend o touchcancel.SyntheticEvent para que se comporten de manera consistente en navegadores.identifier único que persiste desde touchstart hasta touchend, lo que permite rastrear multi-toque.changedTouches contiene solo los toques que cambiaron en el evento actual, mientras que touches contiene todos los toques activos.Detección de deslizamiento izquierda/derecha con velocidad:
"use client";
import { useRef, useCallback } from "react";
export function useSwipeWithVelocity(
onSwipe: (dir: "left" | "right", velocity: number) => void
) {
const startRef = useRef({ x: 0, time: 0 });
const handleTouchStart = useCallback((e: React.TouchEvent) => {
startRef.current = {
x: e.changedTouches[0].clientX,
time: Date.now(),
};
}, []);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
const dx = e.changedTouches[0].clientX - startRef.current.x;
const dt = Date.now() - startRef.current.time;
const velocity = Math.abs(dx) / dt; // px por ms
// Deslizamiento rápido (alta velocidad) necesita menos distancia
const threshold = velocity > 0.5 ? 30 : 80;
if (Math.abs(dx) < threshold) return;
onSwipe(dx > 0 ? "right" : "left", velocity);
},
[onSwipe]
);
return { onTouchStart: handleTouchStart, onTouchEnd: handleTouchEnd };
}Pinch-to-zoom:
"use client";
import { useState, useRef, useCallback } from "react";
function getDistance(t1: React.Touch, t2: React.Touch): number {
return Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
}
export function PinchZoomImage({ src, alt }: { src: string; alt: string }) {
const [scale, setScale] = useState(1);
const initialDistance = useRef(0);
const initialScale = useRef(1);
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if (e.touches.length === 2) {
initialDistance.current = getDistance(e.touches[0], e.touches[1]);
initialScale.current = scale;
}
},
[scale]
);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (e.touches.length === 2) {
e.preventDefault();
const currentDistance = getDistance(e.touches[0], e.touches[1]);
const ratio = currentDistance / initialDistance.current;
setScale(Math.min(Math.max(initialScale.current * ratio, 0.5), 4));
}
}, []);
return (
<div
className="overflow-hidden touch-none"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
>
<img
src={src}
alt={alt}
className="w-full transition-transform"
style={{ transform: `scale(${scale})` }}
/>
</div>
);
}Presión larga en dispositivos móviles:
"use client";
import { useRef, useCallback } from "react";
export function useLongPress(onLongPress: () => void, delay = 500) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const start = useCallback(
(e: React.TouchEvent) => {
e.preventDefault();
timerRef.current = setTimeout(onLongPress, delay);
},
[onLongPress, delay]
);
const cancel = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
return {
onTouchStart: start,
onTouchEnd: cancel,
onTouchMove: cancel,
onTouchCancel: cancel,
};
}Tacto vs clic -- manejar ambos sin disparo doble:
"use client";
import { useRef, useCallback } from "react";
export function useTapOrClick(handler: () => void) {
const touchedRef = useRef(false);
const onTouchEnd = useCallback(
(e: React.TouchEvent) => {
touchedRef.current = true;
e.preventDefault(); // previene el evento de clic subsecuente
handler();
},
[handler]
);
const onClick = useCallback(() => {
// Solo se dispara en dispositivos sin tacto ya que preventDefault de tacto lo detiene
if (!touchedRef.current) {
handler();
}
touchedRef.current = false;
}, [handler]);
return { onTouchEnd, onClick };
}Prevenir desplazamiento durante interacción de tacto:
"use client";
import { useEffect, useRef } from "react";
export function usePreventScrollDuringTouch(active: boolean) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || !active) return;
const prevent = (e: TouchEvent) => e.preventDefault();
// Debe usar { passive: false } para permitir preventDefault
el.addEventListener("touchmove", prevent, { passive: false });
return () => el.removeEventListener("touchmove", prevent);
}, [active]);
return ref;
}// El tipo de evento central
function handleTouch(e: React.TouchEvent<HTMLDivElement>) {
// changedTouches es una TouchList -- tipo array, no un array real
const touch: React.Touch = e.changedTouches[0];
const x: number = touch.clientX;
const y: number = touch.clientY;
const id: number = touch.identifier;
// Convertir TouchList a array para iteración
const allTouches: React.Touch[] = Array.from(e.touches);
}
// Tipado de elemento genérico
function handleTouchOnCanvas(e: React.TouchEvent<HTMLCanvasElement>) {
const canvas = e.currentTarget; // tipado como HTMLCanvasElement
const rect = canvas.getBoundingClientRect();
const touch = e.changedTouches[0];
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
}
// Tipo de retorno de hook personalizado
type SwipeHandlers = {
onTouchStart: (e: React.TouchEvent) => void;
onTouchMove: (e: React.TouchEvent) => void;
onTouchEnd: (e: React.TouchEvent) => void;
};
// Rastreo de identificador de toque en eventos
const activeTouches = useRef<Map<number, { startX: number; startY: number }>>(
new Map()
);touches está vacío en onTouchEnd -- Cuando el último dedo se levanta, touches y targetTouches están vacíos porque no quedan dedos. Solución: Usa changedTouches en onTouchEnd para obtener la posición del dedo que acaba de levantarse.
e.preventDefault() en manejadores de tacto no hace nada con listeners pasivos -- React 17+ registra eventos de tacto como pasivos por defecto en la raíz, lo que significa que preventDefault() en onTouchMove falla silenciosamente y la página sigue desplazándose. Solución: Usa un ref y adjunta el listener manualmente con { passive: false }, o aplica touch-action: none en CSS.
Los eventos de tacto disparan un clic sintético 300ms después -- En navegadores móviles, un touchend es seguido por un evento click retrasado. Si manejas tanto onTouchEnd como onClick, el manejador se dispara dos veces. Solución: Llama a e.preventDefault() en onTouchEnd para suprimir el clic, o usa eventos de puntero en su lugar.
TouchList no es un array -- e.touches, e.targetTouches y e.changedTouches son objetos TouchList, no arrays. Llamar a .map() o .filter() directamente lanza un error. Solución: Convierte con Array.from(e.touches) antes de iterar.
Los valores identifier de multi-toque no son secuenciales -- No asumas que los identificadores de toque comienzan en 0 o se incrementan en 1. El navegador asigna IDs de números enteros arbitrarios. Solución: Rastrea toques usando un Map<number, TouchData> indexado por identifier.
Acceder a propiedades nativeEvent después del trabajo asincrónico -- React agrupa eventos sintéticos. Si almacenas el evento y lo lees más tarde, las propiedades pueden anularse. Solución: Extrae los valores que necesitas sincrónicamente (const x = e.changedTouches[0].clientX) antes de cualquier await o setState.
El pinch zoom entra en conflicto con el zoom del navegador -- El pinch de dos dedos activa el zoom nativo del navegador en muchos navegadores móviles, compitiendo con tu manejador de pinch personalizado. Solución: Establece touch-action: none en el elemento y agrega <meta name="viewport" content="... user-scalable=no"> (pero ten en cuenta que esto afecta la accesibilidad).
| Alternativa | Usar Cuando | No Usar Cuando |
|---|---|---|
Pointer Events (onPointerDown, etc.) | Quieres manejo unificado de ratón + tacto + lápiz | Necesitas específicamente información de multi-toque como touches y changedTouches |
CSS touch-action | Quieres controlar desplazamiento/zoom sin JS | Necesitas responder a la posición del toque o datos de gesto |
| Bibliotecas de gestos (use-gesture, Hammer.js) | Necesitas gestos complejos como rotar, pinza, fling con física | Un deslizamiento simple o toque es suficiente |
Solo onClick | Toques y clics se comportan igual para tu caso de uso | Necesitas arrastrar, deslizar o multi-toque |
CSS scroll-snap | Quieres comportamiento de carrusel deslizable | Necesitas control programático sobre umbrales de deslizamiento o animaciones |
touches -- todos los dedos actualmente en la pantallatargetTouches -- todos los dedos en el elemento que originó el toquechangedTouches -- solo los dedos que cambiaron en este evento específico (comenzaron, se movieron o se levantaron)onTouchEnd, usa changedTouches porque touches ya no incluye el dedo levantadopreventDefault()addEventListener("touchmove", handler, { passive: false })touch-action: none en CSS en el elementoonTouchStart y compárala con la posición final en onTouchEndtouches, changedTouches) que faltan en eventos de punterofunction handleCanvasTouch(e: React.TouchEvent<HTMLCanvasElement>) {
const rect = e.currentTarget.getBoundingClientRect();
const touch = e.changedTouches[0];
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
// dibujar en (x, y)
}onTouchEnd se dispara primero, luego un click sintético y a veces mousedown/mouseupe.preventDefault() en tu manejador de tacto (con un listener no pasivo) para suprimir el evento del ratónRevisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥