Touch Events
Handle touch interactions on mobile devices -- taps, swipes, pinch gestures, and multi-touch.
Search across all documentation pages
Handle touch interactions on mobile devices -- taps, swipes, pinch gestures, and multi-touch.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
| Event | Fires When | Event Object |
|---|---|---|
onTouchStart | A finger touches the screen | React.TouchEvent |
onTouchMove | A finger moves while touching the screen | React.TouchEvent |
onTouchEnd | A finger lifts off the screen | React.TouchEvent |
onTouchCancel | The system cancels the touch (e.g., incoming call, gesture conflict) | React.TouchEvent |
Each React.TouchEvent exposes:
| Property | Type | Description |
|---|---|---|
touches | TouchList | All fingers currently on the screen |
targetTouches | TouchList | Fingers on the current target element |
changedTouches | TouchList | Fingers involved in the current event |
Each Touch object contains: identifier, clientX, clientY, screenX, screenY, pageX, pageY, target.
Quick-reference recipe card -- copy-paste ready.
// SwipeHandler.tsx -- basic swipe detection
"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 };
}When to reach for this: You need to detect swipe gestures on mobile and cannot rely on CSS scroll-snap or a gesture library.
// 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>
);
}What this demonstrates:
onTouchStart, onTouchMove, and onTouchEnd together for swipe-to-navigatetouchstart -> touchmove (repeated) -> touchend or touchcancel.SyntheticEvent so they behave consistently across browsers.identifier that persists from touchstart through touchend, enabling multi-touch tracking.changedTouches contains only the touches that changed in the current event, while touches contains all active touches.Swipe left/right detection with velocity:
"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 per ms
// Fast swipe (high velocity) needs less distance
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>
);
}Long press on mobile:
"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,
};
}Touch vs click -- handling both without double-fire:
"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(); // prevents the subsequent click event
handler();
},
[handler]
);
const onClick = useCallback(() => {
// Only fires on non-touch devices since touch preventDefault stops it
if (!touchedRef.current) {
handler();
}
touchedRef.current = false;
}, [handler]);
return { onTouchEnd, onClick };
}Preventing scroll during touch interaction:
"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();
// Must use { passive: false } to allow preventDefault
el.addEventListener("touchmove", prevent, { passive: false });
return () => el.removeEventListener("touchmove", prevent);
}, [active]);
return ref;
}// The core event type
function handleTouch(e: React.TouchEvent<HTMLDivElement>) {
// changedTouches is a TouchList -- array-like, not a real array
const touch: React.Touch = e.changedTouches[0];
const x: number = touch.clientX;
const y: number = touch.clientY;
const id: number = touch.identifier;
// Convert TouchList to array for iteration
const allTouches: React.Touch[] = Array.from(e.touches);
}
// Generic element typing
function handleTouchOnCanvas(e: React.TouchEvent<HTMLCanvasElement>) {
const canvas = e.currentTarget; // typed as HTMLCanvasElement
const rect = canvas.getBoundingClientRect();
const touch = e.changedTouches[0];
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
}
// Custom hook return type
type SwipeHandlers = {
onTouchStart: (e: React.TouchEvent) => void;
onTouchMove: (e: React.TouchEvent) => void;
onTouchEnd: (e: React.TouchEvent) => void;
};
// Touch identifier tracking across events
const activeTouches = useRef<Map<number, { startX: number; startY: number }>>(
new Map()
);touches is empty in onTouchEnd -- When the last finger lifts, touches and targetTouches are empty because no fingers remain. Fix: Use changedTouches in onTouchEnd to get the position of the finger that just lifted.
e.preventDefault() in touch handlers does nothing with passive listeners -- React 17+ registers touch events as passive by default on the root, which means preventDefault() in onTouchMove silently fails and the page still scrolls. Fix: Use a ref and attach the listener manually with { passive: false }, or apply touch-action: none in CSS.
Touch events fire a synthetic click 300ms later -- On mobile browsers, a touchend is followed by a delayed click event. If you handle both onTouchEnd and onClick, the handler fires twice. Fix: Call e.preventDefault() in onTouchEnd to suppress the click, or use pointer events instead.
TouchList is not an array -- e.touches, e.targetTouches, and e.changedTouches are TouchList objects, not arrays. Calling .map() or .filter() directly throws. Fix: Convert with Array.from(e.touches) before iterating.
Multi-touch identifier values are not sequential -- Do not assume touch identifiers start at 0 or increment by 1. The browser assigns arbitrary integer IDs. Fix: Track touches using a Map<number, TouchData> keyed by identifier.
Accessing nativeEvent properties after async work -- React pools synthetic events. If you store the event and read it later, the properties may be nullified. Fix: Extract the values you need synchronously (const x = e.changedTouches[0].clientX) before any await or setState.
Pinch zoom conflicts with browser zoom -- Two-finger pinch triggers the browser's native zoom on many mobile browsers, racing against your custom pinch handler. Fix: Set touch-action: none on the element and add <meta name="viewport" content="... user-scalable=no"> (but be aware this hurts accessibility).
| Alternative | Use When | Don't Use When |
|---|---|---|
Pointer Events (onPointerDown, etc.) | You want unified mouse + touch + pen handling | You specifically need multi-touch info like touches and changedTouches |
CSS touch-action | You want to control scrolling/zooming without JS | You need to respond to touch position or gesture data |
| Gesture libraries (use-gesture, Hammer.js) | You need complex gestures like rotate, pinch, fling with physics | A simple swipe or tap is sufficient |
onClick only | Taps and clicks behave the same for your use case | You need drag, swipe, or multi-touch |
CSS scroll-snap | You want swipeable carousel behavior | You need programmatic control over swipe thresholds or animations |
touches -- all fingers currently on the screentargetTouches -- all fingers on the element that originated the touchchangedTouches -- only the fingers that changed in this specific event (started, moved, or lifted)onTouchEnd, use changedTouches because touches no longer includes the lifted fingerpreventDefault()addEventListener("touchmove", handler, { passive: false })touch-action: none in CSS on the elementonTouchStart and compare with the end position in onTouchEndtouches, changedTouches) that pointer events lackfunction 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;
// draw at (x, y)
}onTouchEnd fires first, then a synthetic click and sometimes mousedown/mouseupe.preventDefault() in your touch handler (with a non-passive listener) to suppress the mouse eventReviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥