Frontend Development 11 min read

How to Stop Scroll‑Penetration in Mobile Web Overlays

This article explains why scroll‑penetration occurs when a modal mask covers the page, why simple overflow:hidden or event‑bubbling tricks fail, and provides a complete solution using passive event listeners, selective default‑preventing, multi‑layer handling, and a ready‑to‑use React component.

Tencent IMWeb Frontend Team
Tencent IMWeb Frontend Team
Tencent IMWeb Frontend Team
How to Stop Scroll‑Penetration in Mobile Web Overlays

When a mask layer covers the

body

and is scrolled, the underlying page scrolls as well, a phenomenon called scroll‑penetration.

What Is Scroll Penetration?

Scrolling the mask triggers scrolling of the content underneath because the browser’s native scroll handling propagates the scroll event to the

Document

target, not because of event bubbling.

Is Stopping Propagation Enough?

Listening to

scroll

/

touchmove

and calling

stopPropagation

does not help; scroll events on normal elements do not bubble, and the browser’s pending scroll queue processes them according to the W3C spec.

Bubbles not on elements, but bubbles to the default view when fired on the document.

Therefore, scroll‑penetration is not a bug but expected behavior: the scroll principle is

scroll for what can scroll

, independent of CSS positioning.

Adding overflow:hidden ?

Setting

overflow:hidden

on

body

prevents scrolling on desktop, but on mobile the body can still scroll if its height exceeds the viewport.

<code>html, body {
  overflow: hidden;
}</code>

To keep the current scroll position, record

scrollTop

before applying

overflow:hidden

and restore it afterward.

Preventing Body’s Default Scroll?

Adding a

touchmove

listener on

document

with

preventDefault

works on iOS but fails on Android because Chrome’s default passive listeners ignore

preventDefault

.

<code>document.addEventListener('touchmove', e => {
  e.preventDefault();
}, { passive: false });</code>

Detect passive support before using the object syntax:

<code>var supportsPassive = false;
try {
  var opts = Object.defineProperty({}, 'passive', {
    get: function() { supportsPassive = true; }
  });
  window.addEventListener('test', null, opts);
} catch (e) {}
</code>

Selective Prevention for Scrollable Elements

Mark scrollable containers with a

can-scroll

class and only prevent default on touches that are not inside those elements:

<code>document.addEventListener('touchmove', e => {
  const excludeEl = document.querySelectorAll('.can-scroll');
  const isExclude = [].some.call(excludeEl, el => el.contains(e.target));
  if (!isExclude) {
    e.preventDefault();
  }
}, { passive: false });</code>

When the user reaches the top or bottom of a scrollable element, prevent the default to stop penetration:

<code>let initialY = 0;
scrollEl.addEventListener('touchstart', e => {
  if (e.targetTouches.length === 1) {
    initialY = e.targetTouches[0].clientY;
  }
});
scrollEl.addEventListener('touchmove', e => {
  if (e.targetTouches.length !== 1) return;
  const deltaY = e.targetTouches[0].clientY - initialY;
  if ((scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight && deltaY < 0) ||
      (scrollEl.scrollTop <= 0 && deltaY > 0)) {
    e.preventDefault();
  }
}, { passive: false });
</code>

Supporting Multiple Overlays

Maintain a

Set

of locked overlays; only when the set is empty should the global scroll listener be removed:

<code>const lockedList = new Set();
function lock() { lockedList.add(this); }
function unlock() {
  lockedList.delete(this);
  if (lockedList.size === 0) {
    // remove global listeners
  }
}
</code>

React Component Wrapper

A ready‑to‑use React component creates a

LockScroll

instance on mount and calls

lock

or

unlock

based on the

lock

prop:

<code>componentDidMount() {
  const opts = this.props.selector || undefined;
  this.lockScroll = new LockScroll(opts);
  this.updateScrollFix();
}
updateScrollFix() {
  const { lock } = this.props;
  if (lock) {
    this.lockScroll.lock();
  } else {
    this.lockScroll.unlock();
  }
}
componentDidUpdate(prevProps) {
  if (prevProps.lock !== this.props.lock) {
    this.updateScrollFix();
  }
}
componentWillUnmount() {
  this.lockScroll.unlock();
}
</code>

Usage:

<code>&lt;ScrollFix lock={show}&gt;
  {/* modal content */}
&lt;/ScrollFix&gt;
</code>

By wrapping any modal with

ScrollFix

and toggling the

lock

prop, developers can avoid scroll‑penetration without worrying about the underlying implementation.

frontendJavaScriptmobile webscroll lockpassive event listenersscroll penetration
Tencent IMWeb Frontend Team
Written by

Tencent IMWeb Frontend Team

IMWeb Frontend Community gathering frontend development enthusiasts. Follow us for refined live courses by top experts, cutting‑edge technical posts, and to sharpen your frontend skills.

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.