React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

touchswipepinchgesturemobilereact-19typescript

Touch Events

Handle touch interactions on mobile devices -- taps, swipes, pinch gestures, and multi-touch.

Event Reference

EventFires WhenEvent Object
onTouchStartA finger touches the screenReact.TouchEvent
onTouchMoveA finger moves while touching the screenReact.TouchEvent
onTouchEndA finger lifts off the screenReact.TouchEvent
onTouchCancelThe system cancels the touch (e.g., incoming call, gesture conflict)React.TouchEvent

Each React.TouchEvent exposes:

PropertyTypeDescription
touchesTouchListAll fingers currently on the screen
targetTouchesTouchListFingers on the current target element
changedTouchesTouchListFingers 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, and onTouchEnd together 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) -> touchend or touchcancel.
  • React normalizes touch events via SyntheticEvent so they behave consistently across browsers.
  • Each touch point has a unique 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.
  • 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

  • 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).

Alternatives

AlternativeUse WhenDon't Use When
Pointer Events (onPointerDown, etc.)You want unified mouse + touch + pen handlingYou specifically need multi-touch info like touches and changedTouches
CSS touch-actionYou want to control scrolling/zooming without JSYou need to respond to touch position or gesture data
Gesture libraries (use-gesture, Hammer.js)You need complex gestures like rotate, pinch, fling with physicsA simple swipe or tap is sufficient
onClick onlyTaps and clicks behave the same for your use caseYou need drag, swipe, or multi-touch
CSS scroll-snapYou want swipeable carousel behaviorYou need programmatic control over swipe thresholds or animations

FAQs

What is the difference between touches, targetTouches, and changedTouches?
  • touches -- all fingers currently on the screen
  • targetTouches -- all fingers on the element that originated the touch
  • changedTouches -- only the fingers that changed in this specific event (started, moved, or lifted)
  • In onTouchEnd, use changedTouches because touches no 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: none in CSS on the element
How do I detect a tap vs a swipe?
  • Track the start position in onTouchStart and compare with the end position in onTouchEnd
  • 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
  • onTouchEnd fires first, then a synthetic click and sometimes mousedown/mouseup
  • Call e.preventDefault() in your touch handler (with a non-passive listener) to suppress the mouse event