How to conditionally lock scrolling on mobile devices

Hello everybody!

Before I get to the topic of this post, I want to mention a few things:

  1. This post is a test post. I wrote it to try a few things regarding this website, not because its content is really interesting.
  2. This post only applies to this website in its current state (see release and update dates above). It may be entirely obsolete when you read this.

With that out of the way, let's get going!

The problem

If you have used this website both on a mobile and a desktop device, you may have noticed that it changes its look to fit the devices size. This is called responsive (web) design, and while important, not particularly impressive in the modern web.

One part that is majorly affected by this is the navigation. On a big screen, you will find that all the links are directly in the header. On a small screen they are hidden behind a hamburger menu. The same also applies to the desktops side bar, which is hidden behind the arrow button in the mobile versions header.

When you open either of these on a small screen, they will take up the entire screen. As you would expect, you can no longer interact with the content behind the menu. And you would be forgiven if you thought the browser does this automatically.

Sadly, in reality, the website author needs to remember to implement this. So in this post I want to explain how I did it.

How the menus work

However, first we need to talk about how the menus work.

While JavaScript is available in basically any browser, some people choose to disable it. While I don't agree with these peoples decision, I still wanted my website to work for them. So I searched for a way to implement these menus without using JavaScript and found it on Stack Overflow.

Basically, we have a checkbox, a label for it and a <div> with our menu content. We make the checkbox and the menu invisible. If the checkbox is checked (the user clicked on the label), we make the menu visible again.

Here is a small code example:

1<input type="checkbox" name="toggle" id="toggle" />
2<label for="toggle">Toggle Button</label>
3<div id="menu">Some Content</div>
1input[type=checkbox],
2#menu {
3 display: none;
4}
5
6#toggle:checked ~ #menu {
7 display: block;
8}

Attempt 1

With our knowledge of the menus functionality from above, we can now set out to solve the problem at hand.

Basically, we just need to test if either of the checkboxes is checked and, if so, disable scrolling on the <html> tag.

The following code resembles the functionality of my first implementation. However, I made it a bit more organized, so I can easily highlight the changes later on.

1function updateScrollLock() {
2 // get the checkbox values
3 let nav = document.getElementById("nav-toggle").checked;
4 let aside = document.getElementById("aside-toggle").checked;
5
6 // get elements to enforce scroll lock
7 let html = document.getElementsByTagName("html")[0];
8
9 // implement lock
10 if (nav || aside) {
11 html.style.overflowY = "hidden";
12 } else {
13 html.style = null;
14 }
15}

At first glance, this seems to work quite well. However, there is a catch.

The header of this website stays fixed to the top of your screen on almost all devices. However, on a vertically small device (like a smartphone in landscape mode) it remains at the top of the page instead. This causes a problem.

With the code above, if a user open a menu in portrait mode and then changes their phone to landscape, they will be left without a way to close it. Here a screenshot of how it looks like on my Google Pixel 5:

A screenshot showing the absence of the page header on a landscaped smartphone.

Attempt 2

We can solve this quite easily by fixing the header to the top of the screen if a menu is opened. We do this with the exact same method as all other form factors, however now we need to do it in JavaScript instead of CSS.

On my site, I use position: sticky; for this, which also requires me to set top: 0;.

In the following snippet, the changes compared to earlier are highlighted.

1function updateScrollLock() {
2 // get the checkbox values
3 let nav = document.getElementById("nav-toggle").checked;
4 let aside = document.getElementById("aside-toggle").checked;
5
6 // get elements to enforce scroll lock
7 let html = document.getElementsByTagName("html")[0];
8 let header = document.getElementsByTagName("header")[0];
9
10 // implement lock
11 if (nav || aside) {
12 html.style.overflowY = "hidden";
13
14 // make sure users can exit menus when turning their phones
15 header.style.position = "sticky";
16 header.style.top = 0;
17 } else {
18 html.style = null;
19 header.style = null;
20 }
21}

Perfect! Now the users will always have a way to close the menu.

However, there still is a problem. If somebody opens the website in a small window on a computer, opens a menu and then makes the screen big enough for the desktop navigation, they will no longer be able to close it (since the close button disappeared) and won't be able to scroll either. We need to fix this!

Attempt 3

This is the most complicated part. We need to check wether we are in mobile or desktop mode. There are a few techniques we could use to determine this, like checking window.innerWidth.

However, I didn't want to specify breakpoints in more than one file, so I decided to rely on a styled element. This element is #aside-toggle-closer and it is set to display: none; when in desktop mode.

To check if this is the case, we first need to use window.getComputedStyle() to get the actual values. element.style only contains the styling rules specific to an element.

With that, we can check if we are on mobile before locking and unlocking scrolling:

1function updateScrollLock() {
2 // check if in mobile mode
3 let mobile =
4 window.getComputedStyle(document.getElementById("aside-toggle-closer"))
5 .display != "none";
6
7 // get the checkbox values
8 let nav = document.getElementById("nav-toggle").checked;
9 let aside = document.getElementById("aside-toggle").checked;
10
11 // get elements to enforce scroll lock
12 let html = document.getElementsByTagName("html")[0];
13 let header = document.getElementsByTagName("header")[0];
14
15 // implement lock
16 if ((nav || aside) && mobile) {
17 html.style.overflowY = "hidden";
18
19 // make sure users can exit menus when turning their phones
20 header.style.position = "sticky";
21 header.style.top = 0;
22 } else {
23 html.style = null;
24 header.style = null;
25 }
26}

Calling update​ScrollLock()

All we now have left to do is to call the function we just wrote. We need to call it on three different occasions:

We can write a function to do this:

1function registerScrollLock() {
2 // when the site is opened
3 updateScrollLock();
4
5 // when the window is resized
6 window.addEventListener("resize", updateScrollLock);
7
8 // when a menu is opened or closed
9 document
10 .getElementById("nav-toggle")
11 .addEventListener("change", updateScrollLock);
12 document
13 .getElementById("aside-toggle")
14 .addEventListener("change", updateScrollLock);
15}

Now we just need to call this function when the page is loaded:

1if (document.readyState != "loading") {
2 registerScrollLock();
3} else {
4 document.addEventListener("DOMContentLoaded", registerScrollLock);
5}

The hope for less JavaScript

While this scroll locking functionality is quite easy to implement, as you have seen, I still dislike it.

While I don't think the use of JavaScript is a problem per se, this is still the only feature of this website I couldn't implement without it, which annoys me considerably. It also means that I have to include script-src: 'self' in my Content Security Policy.

So I tried to find a CSS based alternative. And happy me I found one: :has(). This should make it possible to write CSS like this:

1html:has(#nav-toggle:checked),
2html:has(#aside-toggle:checked) {
3 overflow-y: hidden;
4}

Now you might be confused. If there is a CSS solution, why did I write this post at all? Well, if you read the linked page to the end, you'll find that currently no browser supports :has(), which honestly seemed like a bad joke when I noticed that.

So all I can do is to wait and hope, that one day I will be able to rip out all JavaScript from my website.

With that said, have a nice day!