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.
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.