A simple fullscreen overlay menu

This is a quick set up for a lightweight (vanilla JS only) and accessible full screen overlay menu.

The requirements for this are pretty straightforward:

  • activate the overlay via a menu open button
  • trap focus within the open overlay until it is closed
  • the close button should receive focus immediately on opening of the overlay
  • the open button should receive focus immediately on closing of the overlay
  • the scroll position of the page beneath the overlay should not change between opening and closing the overlay

Setting up the page

The set up is pretty straightforward: the overlay, a button to open the overlay and a button to close it.

Here’s the overlay and the close button it contains:

<div class="c-nav-overlay" id="nav-overlay" role="navigation" aria-expanded="false">
    <button type="button" name="close" class="c-nav-toggle c-nav-toggle--closer" id="nav-closer" aria-controls="nav-overlay">Menu</button>
    <!—- nav menu goes here -—>
</div>

And the button to open the overlay:

<button type="button" name="open" class="c-nav-toggle c-nav-toggle--opener" id="nav-opener" aria-controls="nav-overlay">Menu</button>

The overlay needs some CSS to handle both the inactive hidden view and the subsequent active open view:

.c-nav-overlay {
    visibility: hidden;
    position: fixed;
    z-index: 800;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    opacity: 0;
    background-color: #000000;
    color: #ffffff;
    overflow-x: hidden;
    overflow-y: scroll;
    -webkit-overflow-scrolling: touch;
    transition: opacity 0.2s, visibility 0.2s;
}

.c-nav-overlay--open {
    opacity: 1;
    visibility: visible;
}

Controlling the scrolling

We can set the <body> to overflow: hidden; to prevent the main page beneath from scrolling when the overlay is open.

Here’s the CSS to do that and to show the overlay:

body.is-overlay-open {
    overflow: hidden;
}

.is-overlay-open .c-menu-overlay {
    opacity: 1;
    visiblity: visible;
}

Trapping the focus

To trap the focus within the overlay when it’s open, I used this great script which did the job perfectly.

With that in place, all that’s left to do is toggle a couple of classes to add the overlay open and close functionality.

/* 
    Trap focus function for the overlay
    https://hiddedevries.nl/en/blog/2017-01-29-using-javascript-to-trap-focus-in-an-element
*/

function trapFocus(element) {
    var focusableEls = element.querySelectorAll('a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select'),
        firstFocusableEl = focusableEls[0];  
        lastFocusableEl = focusableEls[focusableEls.length - 1];
        KEYCODE_TAB = 9;

    element.addEventListener('keydown', function(e) {
        var isTabPressed = (e.key === 'Tab' || e.keyCode === KEYCODE_TAB);

        if (!isTabPressed) { 
            return; 
        }

        if ( e.shiftKey ) /* shift + tab */ {
            if (document.activeElement === firstFocusableEl) {
                lastFocusableEl.focus();
                e.preventDefault();
            }
        } else /* tab */ {
            if (document.activeElement === lastFocusableEl) {
                firstFocusableEl.focus();
                e.preventDefault();
            }
        }

    });
}

/* 
    onclick functions to launch the overlay, toggle the required classes and grab the current scroll position of the page
*/

document.addEventListener("DOMContentLoaded", function() {
    
    var overlay = document.getElementById('nav-overlay');
    var scrollPos = document.querySelector("body").scrollTop; 

    document.querySelector('.c-nav-toggle--opener').addEventListener("click", function() {
        document.querySelector(".c-nav-overlay").classList.toggle("c-nav-overlay--open");
        document.querySelector("body").classList.toggle("is-overlay-open");
        document.getElementById("nav-overlay").setAttribute("aria-expanded", "true");
        document.getElementById('nav-closer').focus();
        trapFocus(overlay);
    });

    document.querySelector('.c-nav-toggle--closer').addEventListener("click", function() {
        document.querySelector(".c-nav-overlay").classList.toggle("c-nav-overlay--open");
        document.querySelector("body").classList.toggle("is-overlay-open");
        document.getElementById("nav-overlay").setAttribute("aria-expanded", "false");
        document.getElementById('nav-opener').focus();
        document.querySelector("body").scrollTop = scrollPos; // [1]
    });

});

Here’s a quick demo Codepen.

Note

There’s always something… it looks like overflow scrolling on the body isn’t prevented as hoped / expected on iOS (at least on versions <12, iOS won’t prevent users from scrolling past the modal if there is content that exceeds the viewport height). Using position: fixed; on the open overlay while also getting the window scroll position at overlay open and returning it to the <body> on overlay close as per the demo will mitigate against this.

And yeah, there is probably a way more elegant way to handle the class toggling events on the buttons…