Scroll Blocking Overlays

9min • 01 September 2023
100% human-written

This is one of those articles I simply wish I wouldn't have to write in 2023. Apparently, it turns out that there is still no straight-forward solution to block background scrolling when working with overlays.

Background

Even though I took a new role as an engineering manager last year, I'm still heavily involved in our design system work at Carla. Not only because I believe that having a proper, accessible design system is extremely useful for a growing company with multiple cross-functional product teams, but also because it is one of my focus areas and passions. Building accessible and user-friendly overlays was one of the biggest challenges so far.

But, before diving into the problem space, let me first explain what I actually mean by scroll blocking overlays. Generally speaking, an overlay is a separate view layer which is not positioned according to the document flow, but rather fixed in front of the application. The most common implementation are modal, also know as pop-ups. They help to create depth and guide the user through your application by highlighting certain elements. Below is a simple example of a modal.

Caution: This is a simplified example for demonstration purpose only. It lacks important user-experience and accessibility features!

If you pay attention, you notice that you can't scroll the page as long as the modal is visible. This is what I refer to as background scrolling. We only want to block background scrolling though! The content of said modal should still be scrollable of course.

Nothing special, right? Well, as we will explore, it's actually not that easy.

The Problem

The most pragmatic approach would probably be to add overflow: hidden on our scrolling element, usually document.body. After all, that's what we do when we want to disable scrolling on an element.

Let's try it! Below you will find the same modal example only using overflow.

Chances are that you're using a desktop device or an up-to-date mobile device. In that case, you probably won't experience any issues at all and the above example works just as fine as the previous one.
But, if you are using an old touch device, especially older version of iOS, you will notice that the background scrolling is not blocked at all.

Fun Fact: There are actually more than 500 results on Stack Overflow (new tab) for this very problem!

The Solution

Now that we know, that we can't just use overflow: hidden at this time, let's explore possible solutions to the problem. I'm going to showcase the major approaches that I came across in the past 6 years and quickly highlight their issues. Finally I will propose what I consider the most novel solution.

Disabling Touch Events

The first solution is rather simple. If we can't solve the issue with CSS, let's use some JavaScript instead and just disable scrolling by preventing touch events.
This can be achieved by hooking into the touchmove event of our scrolling element.

const scrollElement = document.scrollingElement

function onTouchMove(event) {
  event.preventDefault()
  return false
}

scrollElement.addEventListener('touchmove', onTouchMove)

Let's try it out.

This approach works great in theory, but apparently either makes scrolling really bumpy or blocks it completely. As soon as we need an overlay that has overflowing content as well, the solution fails to do the job. Additionally, this doesn't even work anymore for later iOS versions. Depending on which version you are, you might not see any effect at all.

overscroll-behavior

Another promising recent addition to CSS is the overscroll-behavior property. It's contain value is interesting, because it stops propagating scroll events to underlaying parent elements.

Try the great demo on MDN (new tab) to see how it works!

This solves the problem of unintentional background scrolling upon reaching the ends of a container, but apparently doesn't prevent the user from scrolling the background itself. It's a nice feature, but apparently not for this use case.

max-height

The third approach is actually quite pragmatic. By using a max-height on body, we can actually prevent it from scrolling in the first place. Instead, we use the main tag as our scrolling element which properly supports overflow: hidden. Remember, the issue only occurs on body itself. We toggle overflow on our main tag by adding/removing an extra class respectively.

body {
  max-height: 100vh;
}

main {
  overflow: auto;
}

main.modal-visible {
  oveflow: hidden;
}

I don't have an example right away as I would have to change the structure of my website, but you can try it out yourself and you will figure out that, generally speaking, it works quite well.
But, upon closer inspection, one will find many flaws with this approach as well. Scroll bars can become a mess and especially on mobile devices scrolling can get very clunky as well. I'm not exactly sure why, but I can imagine browsers optimise for body scrolling in some way.

body-scroll-lock

Alright. So far we've only seen imperfect solutions. At this point one might wonder if there even is a proper working solution. After some research you eventually run into a pretty popular package sooner or later: body-scroll-lock (new tab).

It's been around for many years with more than 700.000 downloads every week, is well tested and in many ways the most reliable solution out there. It ships with all battery packs included to make sure every edge case, every browser is supported while keeping a really simple API.

import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock'

const modalElement = document.getElementById('unique-modal')

// enable scroll once the modal becomes visible
enableBodyScroll(modalElement)

// disable scroll when the modal is hidden
disableBodyScroll(modalElement)

If you need to support a wide range of legacy browsers, I'd definitely recommend going with this package! Yet, its battery packs come at a cost. The source code has no less than 280 lines of code and weights 1.3kb minified and gzipped. To put it into perspective: React is only twice as heavy.

position: fixed

Luckily, if we don't need support for legacy browsers - especially older iOS versions which are rarely used anymore - there's a neat solution that only relies on CSS.
Using position: fixed on body, we can block it's scroll without restricting its height itself. We only have to keep track of the scroll offset to prevent the page from jumping around and to restore it later on.

// you could also parse the offset from the style directly
// if you'd prefer no having mutating variables
let scrollOffset
const scrollElement = document.scrollingElement

function blockScrolling() {
  scrollOffset = window.pageYOffset

  scrollElement.style.overflow = 'hidden'
  scrollElement.style.position = 'fixed'
  scrollElement.style.top = -scrollOffset + 'px'
}

function enableScrolling() {
  scrollElement.style.removeProperty('position')
  scrollElement.style.removeProperty('overflow')
  scrollElement.style.removeProperty('top')

  window.scrollTo(0, scrollOffset)
}

Beautiful, isn't it? With only a couple lines of code, we can actually make it work reliably in all modern browsers.

Usage with React

Since most of us are not using vanilla JavaScript to build modern web applications, we decided to open source our React implementation based on this solution: react-scroll-blocking-layers (new tab).

It supports multiple layers out of the box and comes with nice extras such as size restrictions to automatically hide layers if a certain maximum width is exceeded which is super useful e.g. for mobile navigation menus.

LayerContextProvider

To use it, we first have to wrap our whole application with the LayerContextProvider (new tab).

For example, in Next.js one would do this in _app.js directly.

import { LayerContextProvider } from 'react-scroll-blocking-layers'

function MyApp() {
  return (
    <LayerContextProvider>
      <App />
    </LayerContextProvider>
  )
}

useLayer

Now that we have the context in place, we can use the useLayer (new tab) hook in any component. It mimics the useState (new tab)-API and works exactly the same, but applies all the scroll blocking logic under the hood.

For size restrictions please check out useLayerWithSizeConstraints (new tab).

import { useLayer } from 'react-scroll-blocking-layers'

import Modal from './Modal'

function Info() {
  const [modalVisible, setModalVisible] = useLayer()

  return (
    <>
      {modalVisible && <Modal onClose={() => setModalVisible(false)} />}
      <button onClick={() => setModalVisible(true)}>Open Modal</button>
    </>
  )
}

We also have a full example with nested overlays and size restriction: https://react-scroll-blocking-layers.vercel.app.

The Future

Great news: overflow: hidden not working on body was finally fixed in Webkit and removed since iOS 16.3! That means, looking at the high adoption rate of new iOS versions, we might actually be able to use nothing but overflow: hidden in the future.

Conclusion

We learned that it is harder than it looks like to reliably block background scrolling. There are many ways to do it, but only 2 really satisfy all the needs of modern web applications and user experience.
With react-scroll-blocking-layers (new tab), we now have a very convenient way to use it with React. And hopefully, sooner than later, we can replace the logic with a single overflow toggle.

Thanks

Thanks to the contributors over at body-scroll-lock (new tab) for building this package that served as a great research resource. Thanks to all the engineers at Carla (new tab) who helped me land on this final position: fixed solution.

To The Top
Picture of Robin Weser

Thanks for reading!

Comments or questions? Reach out to me via Twitter (new tab) or Mail (new tab).
Subscribe to my newsletter to get notified of new articles!

Enjoyed the article?