How to add touch-swipe navigation in React
Mouse wheels and arrow keys are great on a desktop, but on a touch screen people expect to swipe. My horizontal-scroll slideshow supports a horizontal swipe to move between sections, and the implementation is refreshingly small: record where a touch starts, see where it ends, and if the finger moved far enough sideways, go that direction. This post walks through the whole thing.
The idea: compare start and end
A swipe is just two points in time — where your finger landed and where it lifted.
The browser gives us touchstart and touchend events for exactly that. We store
the horizontal position at the start, then compare it to the horizontal position
at the end. The difference tells us both how far and which way the finger traveled.
flowchart LR
A["touchstart: record startX"] --> B["touchend: read endX"]
B --> C["diff = startX - endX"]
C --> D{"abs(diff) > 50?"}
D -->|no| E["ignore (a tap/jitter)"]
D -->|yes| F["diff > 0 ? next : prev"]The code
Here’s the full effect:
// src/App.tsx
useEffect(() => {
if (isMobile) return
let startX = 0
const onTouchStart = (e: TouchEvent) => {
startX = e.touches[0].clientX
}
const onTouchEnd = (e: TouchEvent) => {
const diff = startX - e.changedTouches[0].clientX
if (Math.abs(diff) > 50) {
setCurrent((c) => {
goTo(c + (diff > 0 ? 1 : -1))
return c
})
}
}
window.addEventListener("touchstart", onTouchStart)
window.addEventListener("touchend", onTouchEnd)
return () => {
window.removeEventListener("touchstart", onTouchStart)
window.removeEventListener("touchend", onTouchEnd)
}
}, [isMobile, goTo])Let’s break down the touch-specific parts.
Recording where the touch starts
const onTouchStart = (e: TouchEvent) => {
startX = e.touches[0].clientX
}A touch event can involve multiple fingers, so it carries a list of touches.
e.touches[0] is the first finger, and clientX is its horizontal pixel position
in the viewport. We stash it in a plain local variable, startX, that both
handlers close over — no state needed, because nothing on screen depends on it
mid-gesture.
Measuring the swipe at the end
const onTouchEnd = (e: TouchEvent) => {
const diff = startX - e.changedTouches[0].clientX
...
}One subtlety: at touchend the finger has already left, so it’s no longer in
e.touches. The touch that just ended lives in e.changedTouches instead. Reading
e.changedTouches[0].clientX gives us the finger’s final position.
diff = startX - endX. If you swipe left (finger moves toward smaller X), endX
is smaller than startX, so diff is positive. Swipe right and diff is
negative. That sign is how we detect direction.
The 50-pixel threshold
if (Math.abs(diff) > 50) {Not every touch is a swipe — taps, long-presses, and tiny finger jitters all fire
touchstart/touchend too. Requiring the finger to have moved more than 50 pixels
horizontally (Math.abs ignores direction here) filters those out. Below 50px we
treat it as an accidental touch and do nothing. Raise the number for a more
deliberate swipe, lower it for a hair-trigger one.
Turning direction into a move
goTo(c + (diff > 0 ? 1 : -1))If diff > 0 (swiped left), we go to the next panel, c + 1 — swiping left
pulls the next section into view, matching how a physical card would move. Swiping
right (diff <= 0) gives -1 and goes back. goTo clamps the index so swiping
past the ends does nothing.
We wrap it in setCurrent((c) => { ...; return c }) to read the latest index
without changing it here — goTo is what actually updates state. Returning c
unchanged means this is a read, not a write.
Takeaways
- A swipe is just
startX(fromtouchstart) versusendX(fromtouchend). - The finished touch is in
e.changedTouches, note.touches, attouchend. - Guard with a distance threshold (
Math.abs(diff) > 50) to ignore taps and jitter. - The sign of
startX - endXgives direction: positive means swiped left, so go to the next panel.