iOS 10 Safari: Prevent scrolling behind a fixed overlay and maintain scroll position [duplicate]
Asked Answered
K

13

45

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>
Khrushchev answered 11/1, 2017 at 15:41 Comment(0)
P
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;
  }
}())
Particularize answered 11/1, 2017 at 21:47 Comment(13)
The code would better be saved in an external file so that it can be cached.Babby
I suppose the same trick will work for Firefox for iOS which seems to have the same issue?Koheleth
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 solutionPantie
@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.Particularize
vue mixin hot delivery! gist.github.com/vovchisko/7222b0270a5953a9074abb5876720a7aFortune
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.Syllabogram
If blocking momentum is desired, I just added my own solution below, influenced by @BohdanDidukh :) -- https://mcmap.net/q/57055/-ios-10-safari-prevent-scrolling-behind-a-fixed-overlay-and-maintain-scroll-position-duplicateSyllabogram
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?Morn
@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.Acetylcholine
Can a class be defined in the overlay wrap & instead of selecting via id getElementsByClassName() used for multiple overlay's?Acetylcholine
Update: Doesn't work on iPhone X and iOS 15. :(Syllabogram
Been through a lot of threads tonight. This works surprisingly well. Device: iPhone-11-Pro Max OS: 15.6.1 Browser SafariPassionless
I also needed to do this in an up to date safari when setting pointer-events: none on a currently active scrolling div.Coltoncoltsfoot
W
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

Winder answered 19/1, 2018 at 12:13 Comment(3)
Yeah this does not work on mobile safari unfortunately. I haven't been able to find any true fixes for scrolling on mobile safari.Breuer
This package stops fixed position modals from scrolling on IOS devices in my case.Perfumer
This package was worth gold to me. Awesome work @Will!Otte
Y
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.

Yellowthroat answered 15/10, 2018 at 12:39 Comment(6)
Tried it on iOS 12, no effect :/Surmise
@Surmise we are using this in production on ios 12, and it works for us.Yellowthroat
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.Khrushchev
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 scrollBreuer
Doesn't work on iOS 13 Even adding pointer-events: none; to every element doesn't disable the scrollingCaston
pointer-events: none seems to not lock scrolling on iOS 15, same for you?Sakti
S
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);
Syllabogram answered 20/8, 2019 at 2:50 Comment(1)
Update: Doesn't work on iPhone X and iOS 15. :(Syllabogram
D
3

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

body {
    -webkit-overflow-scrolling: touch;
}
Donetta answered 3/9, 2019 at 14:18 Comment(3)
Nice. I didn't think of that.Khrushchev
Non standard: developer.mozilla.org/en-US/docs/Web/CSS/…Chokebore
It doesn't matter if a workaround for a platform-specific problem is standard or not. (Doesn't help, though. I think something changed around iOS 13.)Type
E
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;
    }
Exeter answered 27/2, 2020 at 12:34 Comment(2)
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.Disendow
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.Ritchie
C
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

Chianti answered 28/4, 2017 at 9:59 Comment(1)
Will not maintain scroll position I'm afraid.Wiseacre
D
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

Dato answered 29/8, 2018 at 6:16 Comment(0)
S
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.

Speckle answered 12/8, 2019 at 4:55 Comment(1)
This is the easiest solution for me too because others get really complicated if there are nested scroll containers.Anther
F
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.

Fushih answered 22/11, 2018 at 10:20 Comment(0)
M
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');
  }
}
Mud answered 11/1, 2021 at 22:3 Comment(1)
Doesn't work on iOS when using fixed position overlays.Khrushchev
E
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();
 }
Erring answered 16/3, 2022 at 9:34 Comment(0)
C
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;
}
Cellulous answered 4/4, 2022 at 8:9 Comment(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.Mcmullin

© 2022 - 2024 — McMap. All rights reserved.