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.

But how do we do this?

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

But what does "window height" really mean. To explore this a measure is visible on the right side of the page. It'll scale with the height defined by the properties used in the examples on this page.

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 you and I both know that this won’t fly because window.innerHeight is a JavaScript property.

Syncing window.innerHeight

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

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

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

The 'resize' event is trigged when the iOS Safari footer changes 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 as we’re going to use Custom Properties for our modal in a minute so we 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’ll 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 why, I just know that I’m so tired. 🤷‍♂️

Let’s just 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 Apple Safari dev team will fix this mess in the next upate, but I’m not holding my breath.

I share web dev tips on Twitter, if you found this interesting and want to learn more, follow me there

Or join my newsletter

More articles More articles