How to prevent scrolling the page on iOS Safari 15

If we show a modal on iOS we need to prevent events inside the modal from interacting with the page behind the modal. On a previous episode of “Fun with Safari” we could use preventDefault() on the "touchmove" event but on iOS 15 that no longer works. Here we go.

To make this work we need iOS to think that there is nothing to scroll.

How do we do this?

We set the height of both the html and the body element to the window height and then assing overflow to hidden so the content gets cut off.

But what does window height really mean. To explore this I’ve added a measure to the right hand side of the page. It’ll scale with the height as defined in the examples.

For now it’s set to 100px.

A height of 100vh

A vh is a viewport height unit, 1vh means 1% of the viewport height.

Great. Let’s set the measure to 100vh and observe what happens.

.measure {
  height: 100vh;
}

Nope. This is not going to work. On iOS 100vh is always the full height of the viewport, even if the footer shows.

If you’re browsing this page on iOS Safari, scroll up and down a bit to see the height stays fixed no matter of the footer is active or not.

Using -webkit-fill-available

Let’s try the -webkit-fill-available property instead.

This is a -webkit- thing, so if you’re on Firefox the measure will now snap to 0

.measure {
  height: -webkit-fill-available;
}

This will do the trick. But it results in a modal that is not filling up all available space.

-webkit-fill-available will fill the “safe” space, so it excludes the room the footer will take up if it’s active.

We want all the space.

It turns out that window.innerHeight reflects the actual available space. So what we want is this.

.measure {
  height: window.innerHeight;
}

But we both know window.innerHeight is a JavaScript property and that this won’t fly.

Syncing window.innerHeight

Luckily we can use CSS Custom Properties to make this tick.

.measure {
  height: var(--height);
}

Now we move to JavaScript land where we update the custom property when the window is resized.

The "resize" event is trigged when the iOS Safari footer is scaled in height, so this nicely aligns our measure with the page height.

window.addEventListener("resize", () => {
  measure.style.setProperty("--height", `${window.innerHeight}px`);
});

I know we could also “just” set measure.style.height but we’re going to use Custom Properties for our modal in a minute so might as well go the extra mile here.

If you’re on iOS see if the measure scales correctly by scrolling up and down a bit.

The modal

We’re almost out of the woods. Our measure is now be the right size no matter if the iOS Safari footer is visible or not.

Now we’re going to apply this height to the html and body elements when our modal is activated. This prevents the page from scrolling.

We add an is-locked class to our html element when we show the modal.

// get reference to the modal
const modal = document.querySelector(".modal");

function showModal() {
  // assign lock to html element
  document.documentElement.classList.add("is-locked");

  // open modal
  modal.classList.add("is-open");
}

function hideModal() {
  // remove lock from html element
  document.documentElement.classList.remove("is-locked");

  // close modal
  modal.classList.remove("is-open");
}

When the is-locked class is assigned we use it to set the required styles so the page cannot be scrolled.

html.is-locked,
html.is-locked body {
  /* want to fix the height to the window height */
  height: calc(var(--window-inner-height) - 1px);

  /* want to block all overflowing content */
  overflow: hidden;

  /* want to exclude padding from the height */
  box-sizing: border-box;
}

/* very basic modal styles for brevity */
.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: black;
  display: none;
}

.model.is-open {
  display: block;
}

Wait a minute, what is that calc() doing there?!

Let me try to explain. If the iOS Safari footer is hidden when the modal opens, iOS will reveal the footer if the user makes a vertical pan gesture, the 1px prevents this from happening. I don’t know. 🤷‍♂️

Let’s move on.

Now we listen for the resize event to keep the --window-inner-height CSS Custom Property synced with the window.innerHeight in JavaScript land.

function syncHeight() {
  document.documentElement.style.setProperty(
    "--window-inner-height",
    `${window.innerHeight}px`
  );
}

window.addEventListener("resize", syncHeight);

To prevent iOS from hiding the footer when the modal is visible and the footer is visible we need to prevent the default action of the "pointermove" event.

If we want to support iOS 14 we need to add "touchmove" as well.

const modal = document.querySelector(".modal");

// helper function to run preventDefault
function preventDefault(e) {
  e.preventDefault();
}

function showModal() {
  document.documentElement.classList.add("is-locked");

  modal.classList.add("is-open");

  // block pointer events
  modal.addEventListener("pointermove", preventDefault);
}

function hideModal() {
  document.documentElement.classList.remove("is-locked");

  modal.classList.remove("is-open");

  // resume pointer events
  modal.removeEventListener("pointermove", preventDefault);
}

We’re nearly done.

Because we’re resizing the document the user scroll position is lost when the modal opens. So we need to remember that position before it opens and restore it when the modal closes.

let scrollY; // we'll store the scroll position here

const modal = document.querySelector(".modal");

function preventDefault(e) {
  e.preventDefault();
}

function showModal() {
  // remember scroll position
  scrollY = window.scrollY;

  document.documentElement.classList.add("is-locked");

  modal.classList.add("is-open");

  modal.addEventListener("pointermove", preventDefault);
}

function hideModal() {
  document.documentElement.classList.remove("is-locked");

  modal.classList.remove("is-open");

  modal.removeEventListener("pointermove", preventDefault);

  // restore scroll position
  window.scrollTo(0, scrollY);
}

There we go! 😅

Try it out on iOS Safari 15 by clicking the “Open modal” button below. It’s been tested with the address bar at the bottom of the viewport (which is the default) and at the top. We can move it to the top by tapping “aA” and then “Show Top Address Bar”.

The modal is slightly transparent on purpose so we can see that the page is scrolled to the top. We can assign the backdrop-filter: blur(20px) style to the modal root element to make this less obvious.

I sincerely hope the developers at Apple working on Safari will fix this mess in the next upate but I’m not holding my breath.

I use Twitter to share new webdevelopment tips and tricks, so Follow me there if you found this interesting and want to learn more.

Rik Schennink

Indie Product Developer

to pqina.nl