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.