Disable Body Scrolling For Open Modals on iOS Devices

When overflow: hidden just isn’t enough

iPhone Rage / © chaffflare / Adobe Stock

Stacey Marks, Engineer

Feb. 18, 2020

THE PROBLEM

Are you creating a website that has a full page modal and want to stop pesky body background scrolling when the modal is open?

Did you realize the method that pretty much works everywhere doesn’t work for Safari on iPhones?

Did you google how to fix this?

Did all of the answer pages point you to the body-scroll-lock package?

And when you tested the most recent version of it on a phone, it didn’t work?

Same.

THE SOLUTION

After scouring the internet for way too many hours, more than I care to admit, I found two solutions that worked for what we needed.

1. Stop everything from scrolling, both body background and everything inside modal.
2. Stop the background from scrolling, while allowing content inside the modal to scroll.

THE FIRST EXAMPLE - Freeze Everything

const isSafari = navigator.userAgent.indexOf("Safari") !== -1;
const isIphone = navigator.userAgent.indexOf("iPhone") !== -1;
const isMobileIosSafari = isSafari && isIphone;
// modalOpen is a boolean assigned when modal is opened
if (modalOpen) {
    document.ontouchmove = (e) => e.preventDefault();
} else {
    document.ontouchmove = (e) => true;
}

The first block of code is checking if we are indeed in Safari on an iPhone, otherwise we run what works for literally everything else.

The second block, we are checking if the modal is open, then run what’s needed to stop everything from scrolling.

Easy enough, right?

But wait! What if I actually need to be able to scroll inside my modal?

We got you covered.

THE SECOND EXAMPLE - Allow Scrolling Inside Modal

let targetElement;
const insideTextModal = [];
useEffect(() => {
  targetElement = document.querySelector("#terms-text-container");
  if (isMobileIosSafari) {
    document
      .querySelectorAll("#terms-text-container *")
      .forEach((node) => insideTextModal.push(node));
    window.addEventListener("touchmove", handleTouchMove, {
      passive: false,
    });
  } else {
    disableBodyScroll(targetElement);
  }
});
const handleTouchMove = (e) => {
  const targetIsElement = e.target.id === targetElement.id;
  const modalTextContainsTarget = insideTextModal.includes(e.target);
  const shouldPreventDefault = !(targetIsElement || modalTextContainsTarget);  
  if (shouldPreventDefault) e.preventDefault();
};
const handleModalClose = (e) => {
  const exitClasses = ["terms-modal-container", "close-button"];
  const isExitClick = _.includes(exitClasses, e.target.className);
  
  if (isExitClick) {
    if (isMobileIosSafari) {
      window.removeEventListener("touchmove", handleTouchMove, {
        passive: false, 
      });
    } else {
      enableBodyScroll(targetElement);
    }
    
    props.setModalInfo(false);
  }
};

For context, this is all in the modal component and only gets called once the modal is actually open. In addition,  the _.includes is from the Lodash library, which would have to be imported at the top of your component.

So, because the site is built using Gatsby, we had to take advantage of useEffect in order to access the DOM element to grab the document.

We grab #terms-text-container, which is the container that contains (ha) the modal’s text, not the entire modal itself. We are just targeting the div that we want to be able to scroll.

After checking if we are in mobile iOS Safari, we want to grab all the nodes (the text, in this case) inside #terms-text-container and add them to the insideTextModal array, which you will see being used later. If we don’t do this and your finger hits a piece of text, it will register as the text <p> but wont register as #terms-text-container and disable scrolling.

Then we add the event listener. Because it's a phone scroll the action is touchmove and we call our method handleTouchMove below, which is defined just below.

Within handleTouchMove, if the event.target (what our finger touches and moves) is either NOT the #terms-text-container, or anything inside of it, which is represented with the array insideTextModal, we preventDefault, aka stop it from scrolling

And once the modal is closed, we remove the touchmove event listener, and that’s it!

OTHER (PERSONAL) TAKEAWAYS


Removing Event Listeners
In order to properly remove an event listener, what you pass to the removeEventListener must be the same as what you passed into the addEventListener.

QA’ing your iPhone Specific Work
When testing iPhone specific situations like this, the issue may not be able to be replicated in the browser, even when using responsive mode. We were only able to see this issue when testing on an iPhone itself, or later, using the Xcode simulator, which was a life saver!!

FOLLOW UP

Have a different way to solve this issue? Please let us know!