Locking body
scroll for modals on iOS
Revisiting old solutions for preventing background scrolling from within modal dialogs & overlays.

I’m a software engineer living and working in East London. I’m currently helping to build a one-stop-shop for the digitisation of alternative assets over at Daphne. Although once strictly front-end, today I work across the whole stack, including dipping my toes into DevOps and writing Rust & Go.
Stop me if you’ve heard this one before:
- You have a site with a fixed overlay, such as a modal.
- You open the modal and scroll it.
- By the time you hit the end of the modal (or even sooner), the background behind it starts coming along for the ride.
For most browsers, setting overflow: hidden
on the body
(once the overlay is open) is sufficient. Not so on mobile Safari:
Over the years loads of smart people, such as Ben Frain, have written about various solutions, ranging from CSS-only tricks to JavaScript event interceptors.
Let’s run through a few of them.
The sledgehammer: listening for touch move
Libraries such as Body Scroll Lock selectively block touchmove
events using JavaScript. This mostly works, but feels very heavy-handed.
You also need to know up front which (if any) root element you want to retain scrolling on, such as the inner content of the modal itself. If you don’t get it right, you risk the user not being able to scroll the modal, or getting stuck.
Wishful thinking: overscroll-behaviour
and touch-action
For a brief moment after iOS 13 was released it seemed like we finally had our answer: -webkit-overflow-scrolling
. Ben’s solution sort of works today, but it’s very easy to still get into weird UI states:
After pinching and zooming, you can still scroll the rest of the page. Even worse, by the end of the above video I can’t move at all, rendering the page broken.
Revisiting a classic: position: fixed
The easy, works-everywhere solution is to use position: fixed
on the body. Unfortunately, doing so famously resets the scroll position to the top of the page.
This might be fine if your trigger is at the top anyway, since the user will already be there (or close enough). However, if you’re invoking the overlay from further down the page, such as from sticky navigation, the user will lose their spot.
There are a couple of workarounds:
Top positioning
You can apply a margin
or negative top
value equal to the scroll distance from the top of the page. Then, when you remove position: fixed
, you simply scroll the page to that point. It’s pretty seamless.
However things can get a bit weird if the user resizes the page, or changes device orientation. Since you can’t rely on the browser to reposition the scroll, you risk having an offset so large that it actually sends the entire content off-screen unless you dynamically adjust it.
Scrolling to the old position
An easier solution is to just scroll to the position, rather than setting an offset. The content can never be offscreen, since you’re not applying any kind of fake positioning or negative margin to keep it in view; you’re simply saving and restoring the scroll to what you think it ought to be.
It may be wrong after a resize, but should never result in a broken looking page.
Sticking with the classics
I still think it’s hard to beat position: fixed
alongside programmatic scrolling:
- You don’t need to know which elements need to retain scroll. If you want the overlay to be scrollable, it can be using
overflow: auto
. - You don’t need to worry about the contents flying off-screen due to an offset that no longer applies to the current viewport.
- It works everywhere, including iOS, without any jank.
Example
Here’s a brief example:
HTML<!-- We use a wrapper for the content which we can scroll/reposition -->
<div class="js-wrapper">
<header>
<button class="js-open">Open overlay</button>
</header>
<main>
<p>My content</p>
</main>
</div>
<div class="js-overlay overlay">
<button class="js-close">Close overlay</button>
</div>
JavaScriptconst openBtn = document.querySelector('.js-open');
const closeBtn = document.querySelector('.js-close');
const overlay = document.querySelector('.js-overlay');
const wrapper = document.querySelector('.js-wrapper');
// Store the offset so we can restore it on close.
let scrollTop = 0;
openBtn.addEventListener('click', () => {
scrollTop = window.scrollY;
wrapper.classList.add('is-fixed');
overlay.classList.add('overlay--is-open');
// Scroll the wrapper, rather than setting an offset
// via `top` or `transform`.
wrapper.scroll(0, scrollTop);
});
closeBtn.addEventListener('click', () => {
wrapper.classList.remove('is-fixed');
overlay.classList.remove('overlay--is-open');
window.scrollTo(0, scrollTop);
});
Sass.is-fixed {
position: fixed;
height: 100%;
width: 100%;
// Allow the main content to be scrolled,
// so we can adjust the scroll position with JS.
overflow: auto;
}
.overlay {
display: none;
height: 100%;
width: 100%;
position: fixed;
top: 0;
left: 0;
overflow: auto;
}
.overlay--is-open {
display: block;
}
Check out the CodePen to get a feel for it:
See the Pen Overlay-scroll position overflow hidden by Jay Freestone (@jayfreestone) on CodePen.
The debug view is also available here if you want to try it out on a mobile device.
Let me know if you run into an easier solution!