Please stop me if you’ve heard this one before. You open a modal, scroll through it, close it, and wind up somewhere else on the page than you were when you opened the modal.
That’s because modals are elements on a page just like any other. It may stay in place (assuming that’s what it’s meant to do) but the rest of page continues to behave as normal.
See the Pen
Avoid body scrollable in safari when modal dialog shown by Geoff Graham (@geoffgraham)
on CodePen.
Sometimes this is a non-issue, like screens that are the exact height of the viewport. Anything else, though, we’re looking at Scroll City. The good news is that we can prevent that with a sprinkle of CSS (and JavaScript) trickery.
Let’s start with something simple
We can make a huge dent to open-modal-page-scrolling™ by setting the height of the entire body to the full height of the viewport and hiding vertical overflow when the modal is open:
body.modal-open {
height: 100vh;
overflow-y: hidden;
}
That’s good and all, but if we’ve scrolled through the <body>
element before opening the modal, we get a little horizontal reflow. The width of the viewport is expanded about 15 pixels more, which is exactly the with of the scroll bar.
See the Pen
Avoid body scrollable in safari when modal dialog shown by Geoff Graham (@geoffgraham)
on CodePen.
Let’s adjust the right padding of the body a bit to avoid that.
body {
height: 100vh;
overflow-y: hidden;
padding-right: 15px; /* Avoid width reflow */
}
Note that the modal needs to be shorter than the height of the viewport to make this work. Otherwise, the scroll bar on the body will be necessary.
Great, now what about mobile?
This solution works pretty great on desktop as well as Android Mobile. That said, Safari for iOS needs a little more love because the body still scrolls when a modal is open when tapping and moving about the touchscreen.
We can set the body to a fixed position as a workaround:
body {
position: fixed;
}
Works now! The body will not respond when the screen is touched. However, there’s still a “small” problem here. Let’s say the modal trigger is lower down the page and we click to open it up. Great! But now we’re automatically scrolled back up to the top of the screen, which is just as disorientating as the scrolling behavior we’re trying to resolve.
See the Pen
Avoid body scrollable in safari when modal dialog shown by Geoff Graham (@geoffgraham)
on CodePen.
Boo!
That’s why we’ve gotta turn to JavaScript
We can use JavaScript to avoid the touch event bubble. We all know there should be a backdrop layer when a modal is open. Unfortunately, stopPropagation
is a little awkward with touch in iOS. But preventDefault
works well. That means we have to add event listeners in every DOM node contained in the modal — not just on the backdrop or the modal box layer. The good news is, many JavaScript libraries can do this, including good ol’ jQuery.
Oh, and one more thing: What if we need scrolling inside the modal? We still have to trigger a response for a touch event, but when reaching the top or bottom of the modal, we still need to prevent bubbling. Seems very complex, so we’re not totally out of the woods here.
Let’s enhance the fixed body approach
This is what we were working with:
body {
position: fixed;
}
If we know the top of the scroll location and add it to our CSS, then the body will not scroll back to the top of the screen, so problem solved. We can use JavaScript for this by calculating the scroll top, and add that value to the body styles:
// When the modal is shown, we want a fixed body
document.body.style.position = 'fixed';
document.body.style.top = `-${window.scrollY}px`;
// When the modal is hidden, we want to remain at the top of the scroll position
document.body.style.position = '';
document.body.style.top = '';
This works, but there’s still a little leakage here after the modal is closed. Specifically, it appears that the page already loses its scroll position when the modal is open and the body set to be fixed. So we have to retrieve the location. Let’s modify our JavaScript to account for that.
// When the modal is hidden...
const scrollY = document.body.style.top;
document.body.style.position = '';
document.body.style.top = '';
window.scrollTo(0, parseInt(scrollY || '0') * -1);
That does it! The body no longer scrolls when a modal is open and the scroll location is maintained both when the modal is open and when it is closed. Huzzah!
See the Pen
Avoid body scrollable in safari when modal dialog shown by Geoff Graham (@geoffgraham)
on CodePen.
What about this:
Create a
position: fixed; overflow: auto;
overlay that expands to the window edge, and put the modal box inside that overlay.Unfortunately not works on iOS safari, try this page, https://getbootstrap.com/docs/4.3/components/modal/
No mention of overscroll-behavior: contain? That should be the solution going forward in my opinion, even if support isn’t there yet.
Unfortunately
overscroll-behavior: contain
does not prevent the scrolling when the content is less or equal to the parent(as in, no overflow).If that would be possible(or fixed in browsers?) you could indeed just set the overscroll behavior on the modal and backdrop and all would be great and performant.
You just need to also set
overflow: auto
on the same element, or do any of a number of things that turn that element into a scrolling container.Even your “huzzah” scrolls for me on Android Chrome.
May I know the details?
Thanks for sharing this article, Brad! We’ve used this technique on a few sites and I’ve been meaning to write about it for a while, but now you’ve saved me the trouble! Genuinely grateful for that.
Weirdly, on some projects this seems to perform between with a
transform: translateY(...)
, but it isn’t consistent. Sometimes that introduces a flicker, sometimes it’s noticeably faster. Just figured I’d share in case others would benefit from experimenting with the vertical offset property. ♂I know
transform: translateY(...)
will breakposition: fixed
when on transition. Do you mean that?Genuine question: can you not just set
overflow: hidden
on thehtml
element rather than thebody
and eliminate this issue completely? (besides the horizontal reflow for scrollbars)I know it works in Firefox/Chrome/Edge/IE on Windows but haven’t checked Safari. I wouldn’t be surprised if Safari differs from the norm on this.
Not work on iOS safari, cannot avoid body scroll when modal is open, even body is covered by backdrop.
Seems Apple do something weird here, just like safari on mac doesn’t support
<input type='date'/>
(supports type, but no behavior), but safari on iOS does.There’s a really light npm package that handles this quite well:
https://www.npmjs.com/package/body-scroll-toggle
It essentially does exactly what you’ve described, but also keeps hold of the existing body styles before changing them.
Seems great. Thank you .
But what if I want the modal to be scrollable if it contains a lot of content?
Or just make the modal body scrollable and set a max-height.
I’ll bet you those 15px scrollbar width won’t work cross-browser. You mmmmmight want overflow:scroll instead, which will force an (always inactive, because you set the height) scroll bar, or do something based on calc(100vw – 100%) as per https://aykevl.nl/2014/09/fix-jumping-scrollbar
Yes, you are right. I’ve learn this
15px
from bootstrap, it works on chrome and safari.Good to see attention to a practical problem that shouldn’t be ignored. This solution also minds the Safari bars resizing and device rotation: https://radogado.github.io/natuive/#modal-window
position: fixed;
forbody
element works fine on basic demo, but has performance issues in real life web pages on mobile. Learned this the hard way while working on fancybox.There’s also the very popular Body scroll lock (BSL) npm package:
https://github.com/willmcpo/body-scroll-lock
I’m not happy with your assumption that a scrollbar is even present. The default setting in macOS is that there simply is none; atleast none that’s occupying space and therefore doesn’t need a padding on the right.
David Walsh has a neat way to find out how much padding is needed!
https://davidwalsh.name/detect-scrollbar-width
The key lies in
someDiv.offsetWidth
which gives you the width including vertical scrollbars. You then compare that tosomeDiv.clientWidth
, which doesn’t include scrollbars, and voila, you know what you need to know!Here is davids code from his article
Safari just fixed the scrolling body problem, so you shouldn’t need
position: fixed
anymoreThis comment made my day. Thanks!
In a quick demo here:
On my iOS 13.5.1 phone the body still scrolls.
Overscroll behavior, while not 100% supported yet, is basically made for exactly this use case.
It stops any scrolling within an element from bubbling upward once the element has hit the end of it’s scrolling capacity.
https://caniuse.com/#feat=css-overscroll-behavior
overscroll-behavior: contain
, that is.It seems that the name of this variable needs to be “scrollY”, not “top”?
const top = document.body.style.top;
https://github.com/willmcpo/body-scroll-lock
Highly recommend.
Hi Brad! No “well actually” from me – just wanted to say thank you for this excellent write up. This article was extremely helpful in building an iOS-friendly modal component.
I came here to share this as well. Mobile Safari can cause some real scrolling headaches, especially when you have a modal that has scrolling and a background that has scrolling.
Related: https://stackoverflow.com/questions/41594997/ios-10-safari-prevent-scrolling-behind-a-fixed-overlay-and-maintain-scroll-posi
And this is why I don’t use hacks… Thanks for the update.
I agree, overscroll-behavior: contain; is the answer to the problem. Everything else seems to be a hack.
Even if it’s not fully supported, and it doesn’t always apply (other comments noted edge cases).
Edge cases need a hack for wider support, but not for the optimal solution. In a few years, this page will be outdated and the actual answer needs to be the first thing demonstrated, not the hacks.
Hi Brad Wu!
I use your code and fixed my problem. Special Thanks. :)
// When the modal is shown, we want a fixed body
document.body.style.position = ‘fixed’;
document.body.style.top =
-${window.scrollY}px
;On my Chrome (OSX) if you follow this sequence, position ‘fixed’ resets the scroll to 0. You first need to set the top value and then set the position ‘fixed’ otherwise your scroll goes to 0 before is being red from window.scrollY
Maybe this is already in the comments, but if the script is activated when the body is already fixed, it will jump the scroll to the top of the page. This was my case because I had nav options activating mega-dropdowns and this script.
Can one assist me with ImgMod: I wish to let it scroll, via mousewheel and not scrollbar, so with fixed body! I don’t succeed in it with the given examples. Thanks.