Ashley Sheridan​

Making an Accessible Hamburger Menu

Posted on


With more and more web usage being performed on mobile devices, increasing every year the, now common, hamburger menu is here to stay. One of the main problems that needs addressing is the usability and accessibility of the menu.

A Typical Example

A fairly normal example of a hamburger menu uses a checkbox and a bit of CSS to handle the display of the two states:

<label for="menu" class="menu-toggle">☰</label> <input type="checkbox" id="menu" class="menu-checkbox"/> <div class="menu"> <a href="#">Home</a> <a href="#">About</a> <a href="#">Blog</a> <a href="#">Contact</a> </div>

The CSS for this is as follows:

.menu-checkbox, .menu { display: none; } .menu-toggle { font-size: 36px; } .menu a { display: block; } .menu-checkbox:checked + .menu { display: block; }

The menu works quite well. There's no reliance on Javascript, and in barely a few lines of code you have a working menu using the checkbox and adjacent sibling selector trick. However, it has a few issues:

  • It only works with a mouse, that label isn't focusable.
  • Because the label can't be focused, you lose out on focus styles to let the user know where they are.
  • The "hamburger" symbol there is actually one of the Eight Trigrams, the Trigram for Heaven. A typical screen reader won't read this symbol out at all, so the label has no accessible name.

Keyboard Handling

The first thing is to let the <label> element actually recieve focus, which can be done by giving it a non-negative tabindex value:

<label for="menu" class="menu-toggle" tabindex="0" id="menu-toggle">☰</label>

This alone doesn't register the normal keyboard events that a typical form element would, and because our actual form element is hidden, we need to fill in the gap with a bit of Javascript:

var menuToggle = document.querySelector("#menu-toggle"); var menu = document.querySelector("#menu"); var enterKeyCode = 13; var spaceKeyCode = 32; menuToggle.addEventListener("keyup", function(event) { if(event.keyCode == enterKeyCode || event.keyCode == spaceKeyCode) { var menuOpen = menu.checked; if(menuOpen) { menu.checked = false; } else { menu.checked = true; } } });

Firstly, I set up a couple of reference variables that I will use to point to the label and the checkbox elements. When a key is pressed and the menu label has focus, I check if the key was the space or enter, which are the typical functional element control keys.

If they were, I then flip the checked state of the checkbox, and the CSS takes care of showing and hiding the menu.

Showing Focus

Giving the label a tabindex value gives it the systems focus styles, which I've always felt aren't actually that good at highlighting the element with enough contrast:

.menu-toggle:focus { outline: 2px solid; }

I've deliberately omitted the colour from the declaration so that it picks up whatever the current foreground colour is.

Improving for the Screen Reader

The last thing on the list was that the label doesn't have a good accessible name, so a screen reader won't have anything very useful to present to the user, it's just read out as "Clickable" to indicate to the user that it's an element that's set up to recieve clicks.

For this, we need to give it a specific label that will override the current content (which can't be read out correctly anyway) and a role that tells the browser that it's a type of interactive element that should have the type of clickable semantics we want.

<label for="menu" class="menu-toggle" tabindex="0" id="menu-toggle" aria-label="Main menu" role="button">☰</label>

However, this doesn't feel very clean. We're clearly trying to use the label like a button, so why don't we actually _make it a button_? We can handle the click events with Javascript, as we're already resorting to using Javascript to handle keyboard events for it. Our HTML is simplified a little to this:

<button class="menu-toggle" id="menu-toggle" aria-label="Main menu">☰</button>

Notice how we can get rid of the tabindex, the role, and the for because we don't need any of those when we're using a real button, all of that comes for free.

The Javascript event handling can be simplified as well:

var menuToggle = document.querySelector("#menu-toggle"); var menu = document.querySelector("#menu"); menuToggle.addEventListener("click", function(event) { var menuOpen = menu.checked; if(menuOpen) { menu.checked = false; } else { menu.checked = true; } });

We can remove the check for specific key codes because our event listener is looking for clicks, so we automatically capture the enter and space key presses when it's focused.

Setting the Expanded State

There's just one more thing; a person who's relying on a screen reader doesn't have any way of knowing yet if the menu is opened or not.

This will require a bit more Javascript to set the aria-expanded state on the menu, which is actually applied to the toggle element that triggers it:

First, we need to set a default expanded state for the menu, which we want to start in the closed position:

<button class="menu-toggle" id="menu-toggle" aria-label="Main menu" aria-expanded="false">☰</button> <input type="checkbox" id="menu" class="menu-checkbox" checked="false"/>

Then we just need to set this state via Javascript at the same time that we're altering the checkbox state for this menu:

if(menuOpen) { menu.checked = false; menuToggle.setAttribute("aria-expanded", false); } else { menu.checked = true; menuToggle.setAttribute("aria-expanded", true); }

Improving the Menu Semantics

The menu currently does work, but it could be better, so let's improve the semantics by using better tags:

<nav class="menu" aria-label="Main menu"> <ul> <li><a href="#">Home</a></li> <li><a href="#">About</a></li> <li><a href="#">Blog</a></li> <li><a href="#">Contact</a></li> </ul> </nav>

As this menu was just a navigation menu, it should be marked up as such. Adding a label in ensures that it is available as a named landmark for those people who are using landmark navigation (as opposed to only tabbing through functional elements) to browser the web. You might notice here that we've duplicated the aria-label value. If this needs to change in the future we would need to update that in 2 places, but we can fix that:

<span hidden id="menu-label">Main menu</span> <button class="menu-toggle" id="menu-toggle" aria-labelledby="menu-label" aria-expanded="false">☰</button> <input type="checkbox" id="menu" class="menu-checkbox" checked="false"/> <nav class="menu" aria-labelledby="menu-label"> ...

I create a hidden element with our text and give it an id which we can use to reference it. Now, normally hidden elements aren't available to a screen reader, but when we reference elements from within aria-labelledby or aria-describedby they become available in the browsers accessibility tree.

Cleaning Things Up

The menu is still using a hidden checkbox for state. Given that we're already using Javascript to set the state of other aspects of the menu, we might as well do it for the visual display itself, and remove the uneccessary checkbox from our markup:

First, I remove the checkbox from the HTML and add an id to the menu because I find it better to have Javascript target by id when I want to deal with one specific thing:

<nav class="menu" aria-labelledby="menu-label" id="menu">

The CSS checkbox sibling selector is replaced with a much neater selector: { display: block; }

The Javascript is where most of the changes have been made:

menuToggle.addEventListener("click", function(event) { var menuOpen = menu.classList.contains("active"); var newMenuOpenStatus = !menuOpen; menuToggle.setAttribute("aria-expanded", newMenuOpenStatus); menu.classList.toggle("active"); });

Instead of getting the current menu status from the checkbox state, I'm looking to see if the menu itself has the active class. The new state is just the inverse of that value, so I can set the aria-expanded value in one line and remove the need for the if/else block. Finally, Element.classList has a very simple toggle() method which can just flip a class on or off an element, so again there's no need for an if/else to perform separate add() and remove() actions.

Finally I made a couple of style changes:

  • I swapped out the Trigram symbol for an SVG background. This gives me full control of the appearance of it and isn't dependent on the fonts installed on a users system. Also, while it's not a problem in this particular instance, we avoid the character being replaced with an emoji over which we have no control whatsoever.
  • I added better styles to the menu and its items. This includes specific styles for hover and focus, which help people navigate the menu.

The end result is something that looks like this:


Leave a comment