Skip to main content

React Gesture Interaction Patterns

Complex gesture interactions—long-press, pinch-zoom, multi-touch rotation—require careful event sequencing and state management. These patterns power modern mobile apps where a single touch or combination of touches triggers app-specific behaviors. Building them in React means coordinating pointer events, calculating geometry (distance, angle), and managing gesture state across components. Libraries like react-use-gesture exist, but understanding the underlying patterns lets you customize and debug effectively.

Implementing Long-Press Gesture Recognition

A long-press is a pointer down followed by stationary holding for 500+ milliseconds. Implement it by tracking time and movement:

import { useState, useRef } from 'react';

export function useLongPress(onLongPress, threshold = 500) {
const [isPressed, setIsPressed] = useState(false);
const timeoutRef = useRef(null);
const startPosRef = useRef(null);
const distanceThresholdRef = useRef(10);

const handlePointerDown = (e) => {
setIsPressed(true);
startPosRef.current = { x: e.clientX, y: e.clientY };

timeoutRef.current = setTimeout(() => {
if (isPressed) {
onLongPress(e);
}
}, threshold);
};

const handlePointerMove = (e) => {
if (!startPosRef.current || !isPressed) return;

const distance = Math.hypot(
e.clientX - startPosRef.current.x,
e.clientY - startPosRef.current.y
);

if (distance > distanceThresholdRef.current) {
setIsPressed(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}
};

const handlePointerUp = () => {
setIsPressed(false);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};

return {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
isPressed,
};
}

export default function LongPressExample() {
const [showMenu, setShowMenu] = useState(false);

const longPress = useLongPress(
() => setShowMenu(true),
500
);

return (
<div
{...longPress}
style={{
width: '200px',
height: '100px',
backgroundColor: longPress.isPressed ? '#fbbf24' : '#3b82f6',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontWeight: 'bold',
cursor: 'pointer',
userSelect: 'none',
}}
>
{showMenu ? 'Menu Opened!' : 'Press and hold'}
</div>
);
}

If the pointer moves more than 10px, the long-press is cancelled (no accidental holds while dragging). Once the threshold is reached, fire the callback.

Multi-Touch Pinch Zoom Recognition

Pinch-zoom requires tracking two touch points simultaneously. Calculate the distance between them on each frame, then compare to detect zoom:

import { useState } from 'react';

export function usePinch(onPinch) {
const [scale, setScale] = useState(1);

const handleTouchMove = (e) => {
if (e.touches.length !== 2) return;

const touch1 = e.touches[0];
const touch2 = e.touches[1];

const distance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY
);

// Store initial distance on first move
if (!e.currentTarget.dataset.initialDistance) {
e.currentTarget.dataset.initialDistance = distance;
}

const initialDistance = parseFloat(e.currentTarget.dataset.initialDistance);
const newScale = distance / initialDistance;

setScale(newScale);
onPinch?.(newScale);
};

const handleTouchEnd = (e) => {
if (e.touches.length < 2) {
e.currentTarget.dataset.initialDistance = null;
}
};

return { onTouchMove: handleTouchMove, onTouchEnd: handleTouchEnd, scale };
}

export default function PinchZoomExample() {
const [scale, setScale] = useState(1);

const pinch = usePinch((newScale) => {
setScale(Math.max(1, Math.min(newScale, 3)));
});

return (
<div
{...pinch}
style={{
width: '100%',
height: '400px',
border: '2px solid #ccc',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f9f9f9',
overflow: 'hidden',
}}
>
<div
style={{
width: '200px',
height: '200px',
backgroundColor: '#8b5cf6',
borderRadius: '8px',
transform: `scale(${scale})`,
transition: 'transform 0.05s',
}}
/>
</div>
);
}

Store the initial distance between the two fingers, then calculate the current distance on each move. Divide current by initial to get the scale factor.

Rotation Gesture with Multi-Touch

Detect rotation by tracking the angle between two touch points:

import { useState, useRef } from 'react';

export function useRotationGesture(onRotate) {
const [rotation, setRotation] = useState(0);
const initialAngleRef = useRef(null);

const handleTouchMove = (e) => {
if (e.touches.length !== 2) return;

const touch1 = e.touches[0];
const touch2 = e.touches[1];

const angle = Math.atan2(
touch2.clientY - touch1.clientY,
touch2.clientX - touch1.clientX
);
const angleDegrees = (angle * 180) / Math.PI;

if (initialAngleRef.current === null) {
initialAngleRef.current = angleDegrees;
}

const deltaRotation = angleDegrees - initialAngleRef.current;
setRotation(deltaRotation);
onRotate?.(deltaRotation);
};

const handleTouchEnd = (e) => {
if (e.touches.length < 2) {
initialAngleRef.current = null;
}
};

return { onTouchMove: handleTouchMove, onTouchEnd: handleTouchEnd, rotation };
}

export default function RotationGestureExample() {
const [rotation, setRotation] = useState(0);

const gesture = useRotationGesture((angle) => setRotation(angle));

return (
<div
{...gesture}
style={{
width: '100%',
height: '400px',
border: '2px solid #ccc',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f9f9f9',
}}
>
<div
style={{
width: '150px',
height: '150px',
backgroundColor: '#ef4444',
borderRadius: '8px',
transform: `rotate(${rotation}deg)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontWeight: 'bold',
}}
>
Rotate me
</div>
</div>
);
}

Use Math.atan2 to calculate the angle between the two fingers. Store the initial angle on first move, then calculate the delta on subsequent moves.

Combining Gestures: Zoom and Rotate Together

Real-world apps often need multiple gestures simultaneously. Combine pinch and rotation detection:

import { useState, useRef } from 'react';

export default function CombinedGestures() {
const [scale, setScale] = useState(1);
const [rotation, setRotation] = useState(0);
const dataRef = useRef({ initialDistance: null, initialAngle: null });

const handleTouchMove = (e) => {
if (e.touches.length !== 2) return;

const touch1 = e.touches[0];
const touch2 = e.touches[1];

// Calculate distance for pinch
const distance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY
);

// Calculate angle for rotation
const angle = Math.atan2(
touch2.clientY - touch1.clientY,
touch2.clientX - touch1.clientX
);

if (!dataRef.current.initialDistance) {
dataRef.current.initialDistance = distance;
dataRef.current.initialAngle = angle;
}

const newScale = distance / dataRef.current.initialDistance;
const newRotation =
((angle - dataRef.current.initialAngle) * 180) / Math.PI;

setScale(Math.max(0.5, Math.min(newScale, 3)));
setRotation(newRotation);
};

const handleTouchEnd = (e) => {
if (e.touches.length < 2) {
dataRef.current = { initialDistance: null, initialAngle: null };
}
};

return (
<div
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{
width: '100%',
height: '400px',
border: '2px solid #ccc',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f9f9f9',
}}
>
<div
style={{
width: '150px',
height: '150px',
backgroundColor: '#06b6d4',
borderRadius: '8px',
transform: `scale(${scale}) rotate(${rotation}deg)`,
transition: 'transform 0.05s',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontWeight: 'bold',
}}
>
Zoom & Rotate
</div>
</div>
);
}

This example tracks both distance (for zoom) and angle (for rotation) simultaneously, creating an intuitive two-finger manipulation interface.

Key Takeaways

  • Long-press gestures require time thresholds and movement tolerance to avoid false positives during scrolling or dragging.
  • Multi-touch gestures use e.touches to access simultaneous touch points; calculate geometry (distance, angle) for pinch and rotation.
  • Always initialize state on the first touch move, not on touch start, to avoid stale values across re-renders.
  • Combine multiple gesture recognizers carefully: store state in a ref to avoid race conditions and ensure consistent gesture behavior.

Frequently Asked Questions

How do I handle gesture conflicts (e.g., long-press vs drag)?

Check the movement distance first. If the pointer moves more than your threshold (usually 10–20px), cancel the long-press and allow dragging. This priority ordering prevents conflicting gestures.

Can I use React Pointer Events instead of Touch Events?

Yes, pointer events unify touch, mouse, and pen input. However, for pinch and rotation, you must use pointerId to track multiple simultaneous pointers, which is more complex than e.touches for native touch.

How do I make gestures work on desktop with a trackpad?

Trackpad pinch events fire WheelEvent with ctrlKey set. Listen to wheel events and check the modifier: if (e.ctrlKey) { handlePinch() }. Rotation is gesture-specific to touch and is unavailable on trackpad.

Should I prevent default browser behavior during gestures?

Call e.preventDefault() on touchmove if you're handling custom zoom or rotate gestures to prevent browser pinch-zoom. Be cautious: preventing default can harm accessibility. Use CSS touch-action: none as an alternative.

How do I ensure gestures work across browsers and devices?

Test on iOS Safari, Android Chrome, and desktop browsers. Different devices have different touch precision and latency. Add tolerance thresholds and test on actual devices, not just emulators.

Further Reading