Touch Events
Handle touch interactions on mobile devices -- taps, swipes, pinch gestures, and multi-touch.
Event Reference
| 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.
Recipe
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.
Working Example
// 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:
- Using
onTouchStart,onTouchMove, andonTouchEndtogether for swipe-to-navigate - Tracking touch delta in state for real-time visual feedback during the swipe
- Applying a threshold to distinguish intentional swipes from accidental touches
- Switching between CSS transitions and no-transition during active touch
Deep Dive
How It Works
- Touch events fire in the order:
touchstart->touchmove(repeated) ->touchendortouchcancel. - React normalizes touch events via
SyntheticEventso they behave consistently across browsers. - Each touch point has a unique
identifierthat persists fromtouchstartthroughtouchend, enabling multi-touch tracking. changedTouchescontains only the touches that changed in the current event, whiletouchescontains all active touches.- On most mobile browsers, touch events fire ~60 times per second during a move, so handlers should stay lightweight.
Variations
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;
}TypeScript Notes
// 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()
);Gotchas
-
touchesis empty inonTouchEnd-- When the last finger lifts,touchesandtargetTouchesare empty because no fingers remain. Fix: UsechangedTouchesinonTouchEndto 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 meanspreventDefault()inonTouchMovesilently fails and the page still scrolls. Fix: Use arefand attach the listener manually with{ passive: false }, or applytouch-action: nonein CSS. -
Touch events fire a synthetic click 300ms later -- On mobile browsers, a
touchendis followed by a delayedclickevent. If you handle bothonTouchEndandonClick, the handler fires twice. Fix: Calle.preventDefault()inonTouchEndto suppress the click, or use pointer events instead. -
TouchList is not an array --
e.touches,e.targetTouches, ande.changedTouchesareTouchListobjects, not arrays. Calling.map()or.filter()directly throws. Fix: Convert withArray.from(e.touches)before iterating. -
Multi-touch
identifiervalues 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 aMap<number, TouchData>keyed byidentifier. -
Accessing
nativeEventproperties 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 anyawaitorsetState. -
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: noneon the element and add<meta name="viewport" content="... user-scalable=no">(but be aware this hurts accessibility).
Alternatives
| 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 |
FAQs
What is the difference between touches, targetTouches, and changedTouches?
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)- In
onTouchEnd, usechangedTouchesbecausetouchesno longer includes the lifted finger
Why does my onTouchMove preventDefault not stop scrolling?
- React registers touch event listeners as passive by default at the root level
- Passive listeners cannot call
preventDefault() - Use a ref and manually attach with
addEventListener("touchmove", handler, { passive: false }) - Or apply
touch-action: nonein CSS on the element
How do I detect a tap vs a swipe?
- Track the start position in
onTouchStartand compare with the end position inonTouchEnd - If the total distance moved is below a threshold (e.g., 10px), treat it as a tap
- If the distance exceeds a swipe threshold (e.g., 50px), treat it as a swipe
- You can also factor in time -- a fast short movement is more likely a tap
Should I use touch events or pointer events in React 19?
- Pointer events are the modern standard and work across mouse, touch, and pen
- Touch events provide multi-touch details (
touches,changedTouches) that pointer events lack - Use pointer events for single-point interactions (click, drag, hover)
- Use touch events when you need multi-touch gestures (pinch, two-finger rotate)
How do I handle touch on a canvas element?
function 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)
}Why do my touch handlers fire twice on some Android devices?
- Some Android browsers fire both touch and mouse events for compatibility
onTouchEndfires first, then a syntheticclickand sometimesmousedown/mouseup- Call
e.preventDefault()in your touch handler (with a non-passive listener) to suppress the mouse event
Related
- Pointer Events -- Unified input across mouse, touch, and pen
- Media & Animation Events -- Handling media and CSS animation lifecycle