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.
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.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.