Locking scroll with :has().

It's finally happened – with the release of Firefox 121.0 :has() has landed in all browsers (though writing ":has() has" is sadly no less awkward). In celebration, I thought it a good time to share one of my favourite practical uses of :has() so far – scroll locks.

Imagine that you need to open a modal window, or flyout menu. To prevent from losing the user's place in the page whilst that modal is open – particularly on mobile devices – it's good practice to prevent the page behind it from scrolling. That's a scroll lock.

Let's take a look at how we might implement one.

Locking scroll. permalink

Probably the simplest way to lock the user from scrolling a document is to add overflow: hidden to the document body.

body.lock-scroll {
overflow: hidden;
}

This class will totally disable scrolling, so we can't just leave it in our HTML. It will need to be conditionally added and removed whenever a modal, flyout or scroll-locking element is active within the page.

One way to solve this might be to adjust the body class when opening or closing the scroll-locking element:

const openModal = () => {
// ...some code to open the modal
// then lock the scroll
document.body.classList.add('lock-scroll');
}

const closeModal = () => {
// ...some code to close the modal
// then unlock the scroll
document.body.classList.remove('lock-scroll');
}

This method is generally fine, but there are some complications.

What happens if you have multiple screen locks at the same time, for example? Let's say that our user clicks a hamburger menu to open a flyout whilst a modal is already open; both UI elements will lock the scrolling but, using the approach above, closing either the modal or the flyout would remove the .lock-scroll class from the body and unlock the scrolling whilst the other element is still active.

Similarly, if you're in a framework with clientside routing then navigating to a different page won't automatically remove the body class. Unfortunately, that means the scroll will still locked when the new page is loaded in.

None of this is insurmountable by any means, but managing the body class to cope with edge-cases takes a bit of effort. CSS can make things simpler.

Solving edge-cases with :has(). permalink

Because :has() lets us modify a parent element based on its contents, handling scroll locks becomes a breeze. We can tweak the CSS declaration on our body element to use :has():

body:has(.lock-scroll) {
overflow: hidden;
}

Now, rather than managing state directly on the body by toggling a .lock-scroll class with Javascript, we can now manage it on the markup of any element that needs to lock the page scroll:

<dialog class="lock-scroll">
<!-- some wonderful modal content -->
</dialog>

Instead of the lock being hinged on JS state-management, we've tied it to the contents of the DOM itself. As long an element with .lock-scroll is in the DOM, the scroll we be locked.

Need to unlock the scroll? remove the element from the DOM. If there are multiple instances of .lock-scroll, then the scroll will remain locked until all of them are gone. The same goes for route changes - if there's no .lock-scroll class present in the new page then the scrolling will be automatically unlocked.

...and that's kinda all there is to it. Lush.

Wrapping up. permalink

The above is a very simple example, but it's one that I've found useful. With support for :has() now across all browsers, it's also one that is increasingly viable. The nature of CSS means it's pretty simple to expand the example to more elabourate use-cases too. You might, for example, wish to bind the scroll lock as a side-effect to changes in data or aria attributes:

body:has(.some-class[aria-expanded="true"]) {
overflow: hidden;
}

Whatever your application, :has() looks to be a really handy tool and I'm stoked to see it finally get full support.

Hi, my name is Robb.

I'm a freelance creative developer helping awesome people to build ambitious yet accessible web projects.

Hire me
© MMXXIV. Gwneud yn Ne Cymru.