ehsan.blog
~/blog/how-to-add-touch-swipe-navigation-in-react — zsh
cat how-to-add-touch-swipe-navigation-in-react.md

How to add touch-swipe navigation in React

·4 min read

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:

tsx
// 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

tsx
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

tsx
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

tsx
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

tsx
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 (from touchstart) versus endX (from touchend).
  • The finished touch is in e.changedTouches, not e.touches, at touchend.
  • Guard with a distance threshold (Math.abs(diff) > 50) to ignore taps and jitter.
  • The sign of startX - endX gives direction: positive means swiped left, so go to the next panel.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.