44

I'm not able to prevent the main body content from scrolling while a fixed position overlay is showing. Similar questions have been asked many times, but all of the techniques that previously worked do not seem to work on Safari in iOS 10. This seems like a recent issue.

Some notes:

  • I can disable scrolling if I set both html and body to overflow: hidden, however that makes the body content scroll to the top.
  • If the content in the overlay is long enough so that it can be scrolled, scrolling is correctly disabled for the main page content. If the content in the overlay is not long enough to cause scrolling, you can scroll the main page content.
  • I included a javascript function from https://blog.christoffer.online/2015-06-10-six-things-i-learnt-about-ios-rubberband-overflow-scrolling/ that disables touchmove while the overlay is showing. This worked previously, but no longer works.

Here's the full HTML source:

<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.2.4/jquery.min.js"></script>
    <style type="text/css">
        html, body {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
        }
        body {
            font-family: arial;
        }
        #overlay {
            display: none;
            position: fixed;
            z-index: 9999;
            left: 0;
            right: 0;
            top: 0;
            bottom: 0;
            overflow: scroll;
            color: #fff;
            background: rgba(0, 0, 0, 0.5);
        }
        #overlay span {
            position: absolute;
            display: block;
            right: 10px;
            top: 10px;
            font-weight: bold;
            font-size: 44px;
            cursor: pointer;
        }
        #overlay p {
            display: block;
            padding: 100px;
            font-size: 36px;
        }
        #page {
            width: 100%;
            height: 100%;
        }
        a {
            font-weight: bold;
            color: blue;
        }
    </style>
    <script>
        $(function() {
            $('a').click(function(e) {
                e.preventDefault();
                $('body').css('overflow', 'hidden');
                $('#page').addClass('disable-scrolling'); // for touchmove technique below

                $('#overlay').fadeIn();
            });
            $('#overlay span').click(function() {
                $('body').css('overflow', 'auto');
                $('#page').removeClass('disable-scrolling'); // for touchmove technique below

                $('#overlay').fadeOut();
            });
        });

        /* Technique from http://blog.christoffer.me/six-things-i-learnt-about-ios-safaris-rubber-band-scrolling/ */
        document.ontouchmove = function ( event ) {
            var isTouchMoveAllowed = true, target = event.target;
            while ( target !== null ) {
                if ( target.classList && target.classList.contains( 'disable-scrolling' ) ) {
                    isTouchMoveAllowed = false;
                    break;
                }
                target = target.parentNode;
            }
            if ( !isTouchMoveAllowed ) {
                event.preventDefault();
            }
        };
    </script>
</head>

<body>
    <div id="overlay">
        <span>&times;</span>
        <p>fixed popover</p>
    </div>

    <div id="page">
        <strong>this is the top</strong><br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        lots of scrollable content<br>
        asdfasdf<br>
        <br>
        <div><a href="#">Show Popover</a></div>
        <br>
        <br>

    </div>

</body>

</html>
Lin Du
  • 88,126
  • 95
  • 281
  • 483
Gavin
  • 7,544
  • 4
  • 52
  • 72

13 Answers13

70

Add -webkit-overflow-scrolling: touch; to the #overlay element.

Then add this JavaScript code at the end of the body tag:

(function () {
  var _overlay = document.getElementById('overlay');
  var _clientY = null; // remember Y position on touch start

  _overlay.addEventListener('touchstart', function (event) {
    if (event.targetTouches.length === 1) {
      // detect single touch
      _clientY = event.targetTouches[0].clientY;
    }
  }, false);

  _overlay.addEventListener('touchmove', function (event) {
    if (event.targetTouches.length === 1) {
      // detect single touch
      disableRubberBand(event);
    }
  }, false);

  function disableRubberBand(event) {
    var clientY = event.targetTouches[0].clientY - _clientY;

    if (_overlay.scrollTop === 0 && clientY > 0) {
      // element is at the top of its scroll
      event.preventDefault();
    }

    if (isOverlayTotallyScrolled() && clientY < 0) {
      //element is at the top of its scroll
      event.preventDefault();
    }
  }

  function isOverlayTotallyScrolled() {
    // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#Problems_and_solutions
    return _overlay.scrollHeight - _overlay.scrollTop <= _overlay.clientHeight;
  }
}())
swift-lynx
  • 3,219
  • 3
  • 26
  • 45
Bohdan Didukh
  • 1,276
  • 18
  • 21
  • 1
    The code would better be saved in an external file so that it can be cached. – Rolf Nov 10 '17 at 14:26
  • 1
    I suppose the same trick will work for Firefox for iOS which seems to have the same issue? – Anthony Kong Aug 16 '18 at 01:59
  • 4
    I have to copy all that js just to disable scrolling in the background when an overlay blanket is on top ? I hope there is a more elegant solution – victor Dec 08 '18 at 09:47
  • @victor, you can try to hide all other elements in the body element or make the popup on the additional route as the main content. – Bohdan Didukh Dec 10 '18 at 10:56
  • 1
    vue mixin hot delivery! https://gist.github.com/vovchisko/7222b0270a5953a9074abb5876720a7a – vovchisko Jul 30 '19 at 11:35
  • It doesn't catch the case when user scrolls up a little bit (say, 5px) and then suddenly scrolls up with a great force! And that's expected based on the code because it's only disabling `touchmove` and not the actual scroll. – Aidin Aug 20 '19 at 01:05
  • 1
    If blocking momentum is desired, I just added my own solution below, influenced by @BohdanDidukh :) -- https://stackoverflow.com/a/57566116/2321594 – Aidin Aug 20 '19 at 02:52
  • Hi, it is now officially implemented as node *body-scroll-lock* module but how to allow specific chilren *allowTouchMove* case to not scroll the body when we touchmove them? – Shashank Bhatt Oct 12 '20 at 09:34
  • @BohdanDidukh how can we use this for multiple overlays? I'm using this in modal window for avoiding the background scroll but it's working on a single one only. – kkdeep Nov 29 '20 at 21:43
  • Can a class be defined in the overlay wrap & instead of selecting via id getElementsByClassName() used for multiple overlay's? – kkdeep Nov 30 '20 at 04:53
  • Update: Doesn't work on iPhone X and iOS 15. :( – Aidin Mar 25 '22 at 20:41
  • 1
    Been through a lot of threads tonight. This works surprisingly well. Device: iPhone-11-Pro Max OS: 15.6.1 Browser Safari – Llama D'Attore Dec 08 '22 at 10:21
  • I also needed to do this in an up to date safari when setting pointer-events: none on a currently active scrolling div. – Kevin Mar 22 '23 at 20:38
13

Combined Bohdan Didukh's approach with my previous approach to create an easy to use npm package to disable/enable body scroll.

https://github.com/willmcpo/body-scroll-lock

For more details on how the solution works, read https://medium.com/jsdownunder/locking-body-scroll-for-all-devices-22def9615177

Will Po
  • 221
  • 3
  • 4
6

I was trying to find a clean solution to this for a long time, and what seems to have worked best for me is setting pointer-events: none; on the body, and then pointer-events: auto; explicitly on the item I want to allow scrolling in.

Firas Dib
  • 2,743
  • 19
  • 38
  • 3
    Tried it on iOS 12, no effect :/ – t0vana May 10 '19 at 14:53
  • @t0vana we are using this in production on ios 12, and it works for us. – Firas Dib May 10 '19 at 18:22
  • 1
    The only way I was able to get this to work is by having a wrapper element for all the site content that's set to `position: absolute; width: 100%; height: 100%; overflow: scroll;` and setting `pointer-events: none` on that wrapper element. Setting it on `body` didn't work. I think this is the best way to go, since Apple seems to keep breaking any other techniques. – Gavin May 21 '19 at 16:22
  • AHH thank you! This worked for me on mobile safari. If I add `pointer-events: none` to both body and the wrapper element, and then `pointer-events: auto` to the element I want to scroll – Jeremy Gottfried Oct 09 '19 at 18:09
  • Doesn't work on iOS 13 Even adding pointer-events: none; to every element doesn't disable the scrolling – Monfa.red Nov 06 '19 at 18:55
  • pointer-events: none seems to not lock scrolling on iOS 15, same for you? – Fred K Aug 08 '22 at 15:55
4

Bohdan's solution above is great. However, it doesn't catch/block the momentum -- i.e. the case when user is not at the exact top of the page, but near the top of the page (say, scrollTop being 5px) and all of a sudden the user does a sudden massive pull down! Bohand's solution catches the touchmove events, but since -webkit-overflow-scrolling is momentum based, the momentum itself can cause extra scrolling, which in my case was hiding the header and was really annoying.

Why is it happening?

In fact, -webkit-overflow-scrolling: touch is a double-purpose property.

  1. The good purpose is it gives the rubberband smooth scrolling effect, which is almost necessary in custom overflow:scrolling container elements on iOS devices.
  2. The unwanted purpose however is this "oversrolling". Which is kinda making sense given it's all about being smooth and not sudden stops! :)

Momentum-blocking Solution

The solution I came up with for myself was adapted from Bohdan's solution, but instead of blocking touchmove events, I am changing the aforementioned CSS attribute.

Just pass the element that has overflow: scroll (and -webkit-overflow-scrolling: touch) to this function at the mount/render time.

The return value of this function should be called at the destroy/beforeDestroy time.

const disableOverscroll = function(el: HTMLElement) {
    function _onScroll() {
        const isOverscroll = (el.scrollTop < 0) || (el.scrollTop > el.scrollHeight - el.clientHeight);
        el.style.webkitOverflowScrolling = (isOverscroll) ? "auto" : "touch";
        //or we could have: el.style.overflow = (isOverscroll) ? "hidden" : "auto";
    }

    function _listen() {
        el.addEventListener("scroll", _onScroll, true);
    }

    function _unlisten() {
        el.removeEventListener("scroll", _onScroll);
    }

    _listen();
    return _unlisten();
}

Quick short solution

Or, if you don't care about unlistening (which is not advised), a shorter answer is:

el = document.getElementById("overlay");
el.addEventListener("scroll", function {
    const isOverscroll = (el.scrollTop < 0) || (el.scrollTop > el.scrollHeight - el.clientHeight);
    el.style.webkitOverflowScrolling = (isOverscroll) ? "auto" : "touch";
}, true);
Aidin
  • 25,146
  • 8
  • 76
  • 67
3

Simply changing the overflow scrolling behavior on the body worked for me:

body {
    -webkit-overflow-scrolling: touch;
}
Zach Saucier
  • 24,871
  • 12
  • 85
  • 147
3

I was also facing same issue on safari(for ios). I gave thought to above all solution. but i was not convinced with the hacks. then i get to know about property touch-action. adding touch-action: none to overlay solved the issue for me. for the problem above add touch-action:none to the span inside overlay.

  #overlay span {
        position: absolute;
        display: block;
        right: 10px;
        top: 10px;
        font-weight: bold;
        font-size: 44px;
        cursor: pointer;
        touch-action: none;
    }
Sandeep vashisth
  • 1,040
  • 7
  • 20
  • 40
  • This is the correct answer for safari > 13. Unfortunately not all apple devices are updated. but `touch-action` combined with `body, html { position: fixed; }` seem to work for now. – Brmm Aug 12 '20 at 17:14
  • This will disable all scrolling in the overlay on mobile devices, which doesn't feel like a good solution or just for very specific cases. – Manuel Jul 20 '23 at 10:34
2

When your overlay is opened, you can add a class like prevent-scroll to body to prevent scrolling of elements behind your overlay:

body.prevent-scroll {
  position: fixed;
  overflow: hidden;
  width: 100%;
  height: 100%;
}

https://codepen.io/claudiojs/pen/ZKeLvq

Claudio
  • 101
  • 3
1

For those using React, I've had success putting @bohdan-didukh's solution in the componentDidMount method in a component. Something like this (link viewable via mobile browsers):

class Hello extends React.Component {
  componentDidMount = () => {
    var _overlay = document.getElementById('overlay');
    var _clientY = null; // remember Y position on touch start

    function isOverlayTotallyScrolled() {
        // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#Problems_and_solutions
        return _overlay.scrollHeight - _overlay.scrollTop <= _overlay.clientHeight;
    }

    function disableRubberBand(event) {
        var clientY = event.targetTouches[0].clientY - _clientY;

        if (_overlay.scrollTop === 0 && clientY > 0) {
            // element is at the top of its scroll
            event.preventDefault();
        }

        if (isOverlayTotallyScrolled() && clientY < 0) {
            //element is at the top of its scroll
            event.preventDefault();
        }
    }

    _overlay.addEventListener('touchstart', function (event) {
        if (event.targetTouches.length === 1) {
            // detect single touch
            _clientY = event.targetTouches[0].clientY;
        }
    }, false);

    _overlay.addEventListener('touchmove', function (event) {
        if (event.targetTouches.length === 1) {
            // detect single touch
            disableRubberBand(event);
        }
    }, false);
  }

  render() {
    // border and padding just to illustrate outer scrolling disabled 
    // when scrolling in overlay, and enabled when scrolling in outer
    // area
    return <div style={{ border: "1px solid red", padding: "48px" }}>
      <div id='overlay' style={{ border: "1px solid black", overflowScrolling: 'touch', WebkitOverflowScrolling: 'touch' }}>
        {[...Array(10).keys()].map(x => <p>Text</p>)}
      </div>
    </div>;
  }
}

ReactDOM.render(
  <Hello name="World" />,
  document.getElementById('container')
);

Viewable via mobile: https://jsbin.com/wiholabuka

Editable link: https://jsbin.com/wiholabuka/edit?html,js,output

Ivan Gozali
  • 2,089
  • 1
  • 27
  • 25
1

In some cases where the body content is hidden behind your overlay, you can store the current scroll position using const scrollPos = window.scrollY, then apply position: fixed; to the body. When the model closes remove the fixed position from the body and run window.scrollTo(0, scrollPos) to restore the previous position.

This was the easiest solution for me with the least amount of code.

Angus Bremner
  • 132
  • 1
  • 9
  • This is the easiest solution for me too because others get really complicated if there are nested scroll containers. – robocub Oct 06 '21 at 12:35
0

I found the code on github. It work on Safari in iOS 10,11,12

/* ScrollClass */
class ScrollClass {
constructor () {
    this.$body = $('body');

    this.styles = {
        disabled: {
            'height': '100%',
            'overflow': 'hidden',
        },

        enabled: {
            'height': '',
            'overflow': '',
        }
    };
}

disable ($element = $(window)) {
    let disabled = false;
    let scrollTop = window.pageYOffset;

    $element
        .on('scroll.disablescroll', (event) => {
            event.preventDefault();

            this.$body.css(this.styles.disabled);

            window.scrollTo(0, scrollTop);
            return false;
        })
        .on('touchstart.disablescroll', () => {
            disabled = true;
        })
        .on('touchmove.disablescroll', (event) => {
            if (disabled) {
                event.preventDefault();
            }
        })
        .on('touchend.disablescroll', () => {
            disabled = false;
        });
}

enable ($element = $(window)) {
    $element.off('.disablescroll');

    this.$body.css(this.styles.enabled);
}
}

use:

Scroll = new ScrollClass();

Scroll.disable();// disable scroll for $(window)

Scroll.disable($('element'));// disable scroll for $('element')

Scroll.enable();// enable scroll for $(window)

Scroll.enable($('element'));// enable scroll for $('element')

I hope it helps you.

Hưng Trịnh
  • 947
  • 1
  • 12
  • 23
0

Simply set body overflow: hidden can prohibit body scroll when modal popups. And set body overflow: auto to make it can scroll again.

function lockBodyScroll(lock) {
  if (lock) {
    $('body').css('overflow', 'hidden');
  } else {
    $('body').css('overflow', 'auto');
  }
}
Frank Cheng
  • 5,928
  • 9
  • 52
  • 80
0

If you are using React and have access to modal state, the easiest way is to add this

 document.ontouchmove = (e) =>{
    if(MODAL_OPEN_STATE) e.preventDefault();
 }
0

Simply add touch-action: pan-x; to the element or overlay that you want and it's children too, it will do the trick.

Add this to your css file and you can use the "ios-no-scroll" class on the main element only.

.ios-no-scroll,
.ios-no-scroll * {
  touch-action: pan-x;
}
Othman
  • 1
  • 1
  • This answer could be improved by explaining how `touch-action:pan-x` differs from `touch-action:none` as suggested by Sandeep vashisth and also making sure to both quote and link any relevant documentation. – Besworks Apr 05 '22 at 20:15