Accessible hamburger buttons without JavaScript

Friday, January 27th, 2023, 12:45 #dev

Over the last decade, hamburger buttons have become the de facto standard to expand larger menus on smaller devices. They are so ubiquitous that most users immediately know what their purpose is, when seen in the top left or right corner. They are also very easy to implement with JavaScript: listen to the onclick event of the button and add or remove a class so you can style the menu with CSS accordingly.

So, if it’s that easy to do, why bother with an HTML and CSS only solution? Well, there are two reasons. Firstly, people can’t load or execute the JavaScript on your page more often than you think (everyone has JavaScript, right?). But more importantly, it is very likely that your user sees an SSR (server side rendered) page served as pure HTML initially, before the JavaScript is loaded.

On SSR pages you want your navigation to work immediately – before any JavaScript is loaded.

I’m sure you’ve experienced it before. You navigated to a site, it seems to have fully loaded, and you press the hamburger button... but nothing happens. You do that again and again until finally the JavaScript finishes loading in the background and the menu opens.

Even if you don’t care about the few people that sometimes aren’t able to execute the JavaScript on your site (which... you probably should) there is good reason to create a hamburger button without JavaScript anyway and remove that frustration.

The hamburger button on this site is built with the exact same technique described in this article. Make sure your browser window is small enough so you can see it, and try it!

The basics

We will be adding some additional elements later on to make sure the site remains accessible for people using screen readers, but the core concept to create a pure HTML & CSS hamburger button is quite easy. It consists of these elements:

  • An (invisible) checkbox above the HTML of our menu with the menustate id
  • A <label> element that acts as the hamburger button and toggles the checkbox by using the for="menustate" attribute
  • CSS to show or hide the menu depending on whether the checkbox is checked by using the general sibling combinator

A very basic example looks like this:

<input aria-hidden="true" type="checkbox" id="menustate" />
<nav>
  <label for="menustate" aria-hidden="true">
    <span class="open"></span>
    <span class="close">×</span>
  </label>
  <ul>
    <li>Home</li>
    <li>Contact</li>
    <li>About</li>
  </ul>
</nav>

<style>
  #menustate,
  nav ul,
  nav .close {
    /* Hide the checkbox, menu and close button by default */
    display: none;
  }
  #menustate:checked ~ nav :is(ul, .close) {
    /*
      Show the menu and close button when the menu is open
      (when the #menustate input field is checked)
    */
    display: block;
  }
  #menustate:checked ~ nav .open {
    /* Hide the open button when the menu is open */
    display: none;
  }
</style>

You can see it in action on codepen (there is another link to a full implementation in the conclusion).

Note that we use aria-hidden="true” on the label because we will build something exclusively for screen readers later in this article.

(Animating the hamburger to transform into the X shape is not in the scope of this article, but here is a short summary: add an svg with the hamburger icon. Instead of showing or hiding one icon or the other, translate and rotate the lines with CSS when the menu is open, so the lines form an “X”. Use the CSS transition property to animate the lines. Inspect the source of the hamburger button on this site to see how it’s done exactly.)

This already got us 90% of the way, but it has a glaring issue: it is not accessible.

Accessibility

We should always strive to make our websites as accessible as possible to make sure that everyone has the best experience possible.

Put yourself in the shoes of a visually impaired person and think how frustrating it must be to get on a page that doesn’t allow you to open the main menu!

But even if accessibility is not required in your particular case (you might be building an internal site where there are no visually impaired people) you want people to be able to navigate your page with the keyboard.

So how do we get there?

Open and Close Buttons

The most accessible way is to use two separate anchor elements (<a>) that will serve as the open and close buttons for the menu.

We’ll employ a similar but slightly different technique than with the label. Instead of the buttons toggling the checked property of our input field, the open link will have a href="#menustate” which means that our input field will become the target. Elements that have the same ID as the URL fragment can be selected in CSS with the target pseudo class. The close link on the other hand will have a href="#” and thus remove the target.

We can then change our CSS selector so it will not only target the input field when it’s :checked but also when it’s the :target:

:is(#menustate:checked, #menustate:target) ~ nav :is(ul, .close) {
  /*
    Select either a <ul> or .close element inside the nav when it’s preceded
    by the #menustate:checked or #menustate:target.
  */
}

Make sure you hide the close or open anchor element when the menu is closed or opened (respectively) with display: none. This makes sure screen readers won’t announce them and they won’t be accessible with the keyboard.

These two links are not meant to be clicked or pressed though (they only serve as targets for the keyboard or screen readers). That’s why they are placed in the exact same spot as our hamburger button (the <label> element) but behind it.

A visualisation of how the hamburger button and the accessibility links are placed.

To make sure screen readers can properly announce what these elements are for, we assign role="button" to both and add <span> elements that are only visible to screen readers inside both links that serve as descriptive texts.

(To test it, you can temporarily remove the label from your page an click the links. Everything should behave the same, with the only difference, that now you get #menustate and # appended to the URL whenever they are pressed.)

Final improvement

We could call it a day here. We have a label that toggles the checkbox when pressed and serves as our hamburger button. And then we have open and close buttons that make the checkbox a :target for accessibility.

There is just one minor improvement that will make this perfect. The anchor links work great, but they have two downsides:

  1. They will force the page to scroll back to the top (depending on your use case that might be desirable but this will not always be what you want)
  2. They add the #menustate and # fragments to your URL when pressed which just isn’t pretty.

In order to fix both issues, we will do some progressive enhancement with JavaScript!

In this case it’s fine to use JavaScript because it’s only an enhancement. The hamburger button works fine without it.

Depending on the framework you're using the actual code for this will look different, but in any case it’s very simple to implement: you add a click event listener to your links, make sure the browser doesn’t actually use the href by using event.preventDefault() and toggle the checked value of the input field instead.

For the open button the code could look like this:

const clickHandler = (event) => {
  event.preventDefault()
  document.querySelector(‘#menustate’).checked = true;
}

Conclusion

There is a bit of work involved in implementing this properly, but given that this is the main navigation of your website it is well worth it. It’s likely that the hamburger button is the first thing your visitors try to press and if that fails they’ll become frustrated.

You can find the full implementation of the button in this codepen with a full example. You can copy the code and adapt the styling to your site.

Feel free to inspect the code on this page as well, to see how the HTML and CSS is implemented for the animation.

And don’t forget to take a short break. Stay healthy!