Frontend Development 16 min read

Understanding and Solving Scroll Chaining Issues on Mobile Browsers

This article explains the causes of scroll chaining on mobile browsers, illustrates two common scenarios that trigger it, and provides a reusable React hook solution—including touch handling, scroll‑parent detection, and event‑preventing logic—to reliably stop unwanted scrolling in overlays.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Understanding and Solving Scroll Chaining Issues on Mobile Browsers

Many front‑end developers encounter unexpected scrolling behavior, known as scroll chaining, when an element reaches its scroll boundary and the parent page starts scrolling.

MDN defines overscroll-behavior and explains the bounce effect on mobile browsers, which leads to scroll chaining.

Two typical scenarios cause scroll chaining: dragging a non‑scrollable element makes the background scroll, and dragging a scrollable element beyond its top or bottom triggers the nearest scrollable ancestor.

Because the specification does not forbid scroll chaining, browsers implement it, resulting in the described issues.

Solution approach: on each touchMove event, locate the nearest scrollable ancestor between event.target and event.currentTarget , then decide whether to call event.preventDefault() based on the scrollability and position of that ancestor.

Implementation includes a reusable useTouch hook that tracks touch start, move, direction, and offsets, a getScrollParent utility that walks up the DOM to find the closest element with overflow scroll, and a useLockScroll hook that combines these to block unwanted scroll chaining on mobile.

import { useRef } from 'react'

const MIN_DISTANCE = 10

type Direction = '' | 'vertical' | 'horizontal'

function getDirection(x: number, y: number) {
  if (x > y && x > MIN_DISTANCE) {
    return 'horizontal'
  }
  if (y > x && y > MIN_DISTANCE) {
    return 'vertical'
  }
  return ''
}

export function useTouch() {
  const startX = useRef(0)
  const startY = useRef(0)
  const deltaX = useRef(0)
  const deltaY = useRef(0)
  const offsetX = useRef(0)
  const offsetY = useRef(0)
  const direction = useRef<Direction>('')

  const isVertical = () => direction.current === 'vertical'
  const isHorizontal = () => direction.current === 'horizontal'

  const reset = () => {
    deltaX.current = 0
    deltaY.current = 0
    offsetX.current = 0
    offsetY.current = 0
    direction.current = ''
  }

  const start = ((event: TouchEvent) => {
    reset()
    startX.current = event.touches[0].clientX
    startY.current = event.touches[0].clientY
  }) as EventListener

  const move = ((event: TouchEvent) => {
    const touch = event.touches[0]
    deltaX.current = touch.clientX < 0 ? 0 : touch.clientX - startX.current
    deltaY.current = touch.clientY - startY.current
    offsetX.current = Math.abs(deltaX.current)
    offsetY.current = Math.abs(deltaY.current)
    if (!direction.current) {
      direction.current = getDirection(offsetX.current, offsetY.current)
    }
  }) as EventListener

  return { move, start, reset, startX, startY, deltaX, deltaY, offsetX, offsetY, direction, isVertical, isHorizontal }
}
type ScrollElement = HTMLElement | Window
const overflowStylePatterns = ['scroll', 'auto', 'overlay']
function isElement(node: Element) { return node.nodeType === 1 }
export function getScrollParent(el: Element, root: ScrollElement | null | undefined = window) {
  let node = el
  while (node && node !== root && isElement(node)) {
    if (node === document.body) return root
    const { overflowY } = window.getComputedStyle(node)
    if (overflowStylePatterns.includes(overflowY) && node.scrollHeight > node.clientHeight) {
      return node
    }
    node = node.parentNode as Element
  }
  return root
}
import { useTouch } from './use-touch'
import { useEffect, RefObject } from 'react'
import { getScrollParent } from './get-scroll-parent'
let totalLockCount = 0
const BODY_LOCK_CLASS = 'adm-overflow-hidden'
export function useLockScroll(rootRef: RefObject<HTMLElement>, shouldLock: boolean | 'strict') {
  const touch = useTouch()
  const onTouchMove = (event: TouchEvent) => {
    touch.move(event)
    const direction = touch.deltaY.current > 0 ? '10' : '01'
    const el = getScrollParent(event.target as Element, rootRef.current) as HTMLElement
    if (!el) return
    if (shouldLock === 'strict') {
      const scrollableParent = getScrollableElement(event.target as HTMLElement)
      if (scrollableParent === document.body || scrollableParent === document.documentElement) {
        event.preventDefault()
        return
      }
    }
    const { scrollHeight, clientHeight, offsetHeight, scrollTop } = el
    let status = '11'
    if (scrollTop === 0) {
      status = offsetHeight >= scrollHeight ? '00' : '01'
    } else if (Math.abs(scrollHeight - clientHeight - scrollTop) < 1) {
      status = '10'
    }
    if (status !== '11' && touch.isVertical() && !(parseInt(status, 2) & parseInt(direction, 2))) {
      if (event.cancelable) event.preventDefault()
    }
  }
  const lock = () => {
    document.addEventListener('touchstart', touch.start)
    document.addEventListener('touchmove', onTouchMove, { passive: false })
    if (!totalLockCount) document.body.classList.add(BODY_LOCK_CLASS)
    totalLockCount++
  }
  const unlock = () => {
    if (totalLockCount) {
      document.removeEventListener('touchstart', touch.start)
      document.removeEventListener('touchmove', onTouchMove)
      totalLockCount--
      if (!totalLockCount) document.body.classList.remove(BODY_LOCK_CLASS)
    }
  }
  useEffect(() => { if (shouldLock) { lock(); return () => unlock() } }, [shouldLock])
}

Key points include calculating the drag direction, determining a binary status (00 no scrollable element, 01 top, 10 bottom, 11 middle), and using bitwise logic to decide when to prevent the default scroll behavior. The hook also handles iOS‑12 quirks and passive‑listener compatibility.

By integrating these hooks, developers can reliably prevent scroll chaining in dialogs, masks, and other full‑screen overlays on mobile devices.

mobileReactHooktouch eventsoverscroll-behaviorscroll chaining
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.