Where stray thoughts land,

Blocking body scroll while dragging a progress bar.

Why we left body-scroll-lock and went back to blocking touchmove ourselves.

The housing-subscription diagnosis page has a progress bar where users drag to adjust their assets. The whole page kept jiggling on iOS Safari while users dragged — body scroll was leaking through. We swapped between two approaches once, then ended up coming back to the original. A short journey through both, and what each cost us.

1. Diagnose

The problem itself was clear. While a user grabs the progress bar’s thumb and drags up and down, the thumb’s touchmove is handled inside the bar, but the document’s default scroll behavior also fires. The result: the bar moves, and so does the entire page. From the user’s perspective, “I’m just trying to move a slider, but the screen is rocking with it.”

Two natural candidates. One: handle touchmove directly and preventDefault() the default scroll. Two: use a library like body-scroll-lock to lock the body itself. Both should only run while dragging, so we’d tie them to an isDragging flag inside useEffect.

We started with the direct-prevent approach, ran into minor issues on certain mobile environments, switched to body-scroll-lock, hit different side effects there, and ended up back at direct-prevent. The trade-offs only became clear after rotating through both.

2. Reproduce

I isolated both approaches to compare them side by side. Direct prevent first:

useEffect(() => {
  if (!isDragging) return
  const preventScroll = (e: TouchEvent) => {
    e.preventDefault()
  }
  document.addEventListener('touchmove', preventScroll, { passive: false })
  return () => {
    document.removeEventListener('touchmove', preventScroll)
  }
}, [isDragging])

The crucial detail is { passive: false }. iOS Safari treats touchmove listeners as passive by default for performance — and preventDefault() is silently ignored on passive listeners. Without explicitly opting out, the listener runs, the console may log a warning, and nothing actually happens. So { passive: false } is required to truly block the default scroll.

Now body-scroll-lock:

useEffect(() => {
  if (isDragging) {
    disableBodyScroll(document.body)
  } else {
    enableBodyScroll(document.body)
  }
  return () => enableBodyScroll(document.body)
}, [isDragging])

Cleaner on the page. But in practice, side effects piled up. The library mutates body styles (overflow, position, etc.), so when drag ends and the body unlocks, scroll position can jump slightly. If you don’t tune allowTouchMove carefully, inner scrollable areas get blocked along with the body. And the library isn’t particularly active in maintenance, so reacting to subtle iOS version differences became hard.

Direct prevent is more code, but the behavior is transparent: while dragging, prevent the default of touchmove. That’s it. Easier to reason about, easier to debug.

3. Fix

In the end, we reverted the progress bar’s useEffect to the direct-prevent approach.

useEffect(() => {
  if (!isDragging) return
  const preventScroll = (e: globalThis.TouchEvent) => {
    e.preventDefault()
  }
  document.addEventListener('touchmove', preventScroll, { passive: false })
  return () => {
    document.removeEventListener('touchmove', preventScroll)
  }
}, [isDragging])

We also added useThrottledValue for the AnimatedNumber (NumberFlow-based) display sitting above the progress bar. While dragging, that number was recomputing every frame, which produced jank on the flip animation. Throttling at 100ms made it feel fluid. Not strictly part of the scroll-lock issue, but a UX adjacent change shipped in the same PR.

4. Prevent recurrence

A few principles solidified through this back-and-forth.

First, know exactly what addEventListener('touchmove', ..., { passive: false }) means. Forgetting { passive: false } and calling preventDefault() produces a silent no-op (with a console warning) on iOS — purely because of mobile performance policy. Without that opt-out, the rest of your code might as well not be there.

Second, don’t underestimate the abstraction cost of off-the-shelf libraries. body-scroll-lock looks like a one-line solution, but underneath it mutates body styles and carries layers of compatibility code, all of which is opaque from the call site. When the area you need to control is small and well-defined, hand-rolling tends to be faster to debug and easier to evolve.

Third, validate touch interactions — drag, scroll, zoom — on real devices, not the simulator. The simulator’s touch model is subtly different from real iOS Safari’s touchmove sequence, so identical code can behave differently. We caught the body-scroll-lock side effects only after building on a physical phone.

There’s no universal answer to “library vs. hand-rolled.” But if the behavior is simple and you need fine-grained control across environments, the option with less hidden surface — i.e., the hand-rolled one — usually stabilizes faster.