Solution to prevent page scrolling with overlay

Pop-up windows are a common form of interaction, and overlays are an essential element of pop-up windows. They are used to separate the page from the pop-up window area and temporarily interrupt page interaction. However, when the overlay appears, if scrolling the page is not handled, the page at the bottom of the overlay will start scrolling. In fact, we do not want it to scroll, so we need to prevent this behavior. When the overlay pops up, it is also known as the issue of scroll penetration to disable scrolling of the page below the overlay. This article introduces some commonly used solutions.

Implementation

First, we need to implement an example of scrolling under the overlay. When we click the pop-up button to display the overlay, if we continue scrolling the mouse, we can see that the page below the overlay can still be scrolled. If scrolling is done within the overlay, when the scroll bar inside the overlay reaches the bottom and scrolling continues, the page below the overlay can still be scrolled. This kind of interaction is confusing. The testing environment for the content in this article is Chrome 96.0.4664.110.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Solution to Prevent Page Scrolling with Overlay</title>
    <style type="text/css">
        #mask{
            position: fixed;
            height: 100vh;
            width: 100vw;
            background: rgba(0, 0, 0, 0.6);
            top: 0;
            left: 0;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .hide{
            display: none !important;
        }
        .long-content > div{
            height: 300px;
        }
        .mask-content{
            width: 300px;
            height: 100px;
            overflow-x: auto;
            background: #fff;
        }
        .mask-content > div{
            height: 300px;
        }
    </style>
</head>
<body>
    <button id="btn">Popup</button>
    <div class="long-content">
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
    </div>
    <div id="mask" class="hide">
        <div class="mask-content">
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
        </div>
    </div>
</body>
    <script type="text/javascript">
        (() => {
            const btn = document.getElementById("btn");
            const mask = document.getElementById("mask");
            btn.addEventListener("click", e => {
                mask.classList.remove("hide");
            })
            mask.addEventListener("click", e => {
                mask.classList.add("hide");
            })
        })();
    </script>
</html>

body hidden

This solution is a commonly used one, which is to add overflow: hidden; to the body when the overlay is opened, and remove this style when the overlay is closed. For example, the login popup on SegmentFault and the Modal dialog of antd use this approach.

The advantage of this solution is that it is simple and convenient, only requiring the addition of a CSS style without complex logic. The disadvantage is that it is less adaptable on mobile devices. On some Android models and in Safari, it is unable to prevent scrolling of the underlying page. Additionally, some models may require adding the overflow: hidden; style to the root element <html> for it to take effect. Furthermore, since the actual content of the page is being clipped when applying this style, the scrollbar will disappear when the style is set, and reappear when the style is removed. Therefore, there will be a certain flickering effect visually. Of course, the scrollbar style can be customized, but that brings up another compatibility issue. And again, this is due to the clipping.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Solution to Disable Page Scroll with Overlay</title>
    <style type="text/css">
        #mask{
            position: fixed;
            height: 100vh;
            width: 100vw;
            background: rgba(0, 0, 0, 0.6);
            top: 0;
            left: 0;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .hide{
            display: none !important;
        }
        .long-content > div{
            height: 300px;
        }
        .body-overflow-hidden{
            overflow: hidden;
        }
        .mask-content{
            width: 300px;
            height: 100px;
            overflow-x: auto;
            background: #fff;
        }
        .mask-content > div{
            height: 300px;
        }
    </style>
</head>
<body>
    <button id="btn">Popup</button>
    <div class="long-content">
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
    </div>
    <div id="mask" class="hide">
        <div class="mask-content">
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
        </div>
    </div>
</body>
    <script type="text/javascript">
        (() => {
            const btn = document.getElementById("btn");
            const mask = document.getElementById("mask");
            const body = document.body;
            btn.addEventListener("click", e => {
                mask.classList.remove("hide");
                body.classList.add("body-overflow-hidden");
            })
            mask.addEventListener("click", e => {
                mask.classList.add("hide");
                body.classList.remove("body-overflow-hidden");
            })
        })();
    </script>
</html>

touch preventDefault

The above solution is not very ideal for mobile devices. If you need to handle it on mobile devices, you can use the touch event to prevent the default behavior. Of course, this is only applicable to mobile devices. If you connect a mouse to your phone via Bluetooth or an adapter, it's a different story. If the content of the overlay does not have a scrollbar, the above method is fine. However, if the content of the overlay has a scrollbar, it will no longer be able to move. Therefore, if there are elements inside the overlay that need to be scrolled, you need to control the logic using JavaScript. But controlling the logic can be quite complex. We can check the event.target element of the event. If the target of the touch is the non-scrollable area of the popup, which is the background overlay, then disable the default event. Otherwise, do not control it. However, another problem arises. We need to prevent scrolling when it reaches the top or bottom, otherwise, when touching the top or bottom of the scrollable area of the popup, the scrollbar will reach the top or bottom, and the body will still scroll along with the popup, making the logic more complex.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Solution to Disable Page Scroll with Overlay</title>
    <style type="text/css">
        #mask{
            position: fixed;
            height: 100vh;
            width: 100vw;
            background: rgba(0, 0, 0, 0.6);
            top: 0;
            left: 0;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .hide{
            display: none !important;
        }
        .long-content > div{
            height: 300px;
        }
        .mask-content{
            width: 300px;
            height: 100px;
            overflow-x: auto;
            background: #fff;
        }
        .mask-content > div{
            height: 300px;
        }
    </style>
</head>
<body>
    <button id="btn">Popup</button>
    <div class="long-content">
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
    </div>
    <div id="mask" class="hide">
        <div class="mask-content">
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
        </div>
    </div>
</body>
    <script type="text/javascript">
        (() => {
            const btn = document.getElementById("btn");
            const mask = document.getElementById("mask");
            const body = document.body;
            const scrollerContainer = document.querySelector(".mask-content");
let targetY = 0; // Record the `clientY` when first pressed
scrollerContainer.addEventListener("touchstart", e => {
    targetY = Math.floor(e.targetTouches[0].clientY);
});
const touchMoveEventHandler = e => {
    if (!scrollerContainer.contains(e.target)) {
        e.preventDefault();
    }
    let newTargetY = Math.floor(e.targetTouches[0].clientY); // The position of the mouse during this movement, used for calculation
    let scrollTop = scrollerContainer.scrollTop; // Current scroll distance
    let scrollHeight = scrollerContainer.scrollHeight; // Height of the scrollable area
    let containerHeight = scrollerContainer.clientHeight; // Height of the visible area
    if (scrollTop <= 0 && newTargetY - targetY > 0) { // Reached the top
        console.log("Reached the top");
        if (e.cancelable) e.preventDefault(); // Must check `cancelable` to avoid scrolling error that cannot be canceled during scrolling
    } else if (scrollTop >= scrollHeight - containerHeight && newTargetY - targetY < 0) { // Reached the bottom
        console.log("Reached the bottom");
        if (e.cancelable) e.preventDefault(); // Must check `cancelable` to avoid scrolling error that cannot be canceled during scrolling
    }
}
btn.addEventListener("click", e => {
    mask.classList.remove("hide");
    body.addEventListener("touchmove", touchMoveEventHandler, { passive: false });
})
mask.addEventListener("click", e => {
    mask.classList.add("hide");
    body.removeEventListener("touchmove", touchMoveEventHandler);
})
})();
</script>
</html>

body fixed

The currently commonly used solution is to fix the element in the view to prevent page scrolling, which can be achieved by setting position: fixed. This way, the element cannot be scrolled. When the overlay is closed, it can be released. Of course, there are some details to consider. When fixing the element in the view, the content will return to the top. Here, we need to record the top value to synchronize it. This way, we can have a more complete solution that is compatible with both mobile and PC. However, whether to control the browser's API compatibility using document.documentElement.scrollTop or window.pageYOffset + window.scrollTo needs to be adapted separately. In the example, to demonstrate that the popup window does not cause the view to reset to the top, the popup button has been moved below.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Solution to Disable Page Scrolling with Overlay</title>
    <style type="text/css">
        #mask{
            position: fixed;
            height: 100vh;
            width: 100vw;
            background: rgba(0, 0, 0, 0.6);
            top: 0;
            left: 0;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .hide{
            display: none !important;
        }
        .long-content > div{
            height: 300px;
        }
        .mask-content{
            width: 300px;
            height: 100px;
            overflow-x: auto;
            background: #fff;
        }
        .mask-content > div{
            height: 300px;
        }
    </style>
</head>
<body>
    <div class="long-content">
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <button id="btn">Popup</button>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
        <div>long content</div>
    </div>
    <div id="mask" class="hide">
        <div class="mask-content">
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
            <div>mask-content</div>
        </div>
    </div>
</body>
    <script type="text/javascript">
        (() => {
            const btn = document.getElementById("btn");
            const mask = document.getElementById("mask");
            const body = document.body;
let documentTop = 0; // Record the `top` position when the button is pressed

btn.addEventListener("click", e => {
    mask.classList.remove("hide");
    documentTop = document.scrollingElement.scrollTop;
    body.style.position = "fixed"
    body.style.top = -documentTop + "px";
})
mask.addEventListener("click", e => {
    mask.classList.add("hide");
    body.style.position = "static";
    body.style.top = "auto";
    document.scrollingElement.scrollTop = documentTop;
})
})();
</script>
</html>

Daily Question

https://github.com/WindrunnerMax/EveryDay

References

https://zhuanlan.zhihu.com/p/373328247 https://ant.design/components/modal-cn/ https://juejin.cn/post/6844903519636422664 https://segmentfault.com/a/1190000038594173 https://www.cnblogs.com/padding1015/p/10568070.html https://blog.csdn.net/licanty/article/details/86590360 https://blog.csdn.net/xiaonuanli/article/details/81015131