Remove Unused CSS

Check your site for unused CSS

Quick & free. No signup needed.

Accessible CSS-Only Hamburger Menu

Example of Accessible CSS-Only Hamburger Menu

At times, CSS can be amazingly powerful. One such case is of building a CSS-only hamburger menu. This article will walk through the construction of a hamburger menu powered by plain HTML and CSS only.

We will build step-by-step the demo seen in the above image.

The menu will consist of the following 2 main parts:

The menu will work as per the following rules:

Let’s get started with building the hamburger.

<div class="hamburger-container">
  <input class="checkbox" type="checkbox" id="hamburger-toggle" aria-label="Toggle Navigation"/>
  <label class="hamburger" for="hamburger-toggle">
    <span class="slice"></span>
    <span class="slice"></span>
    <span class="slice"></span>
  </label>
  <div class="drawer">
    <ul class="nav-list">
      <li class="nav-list-item"><a href="#">Home</a></li>
      <li class="nav-list-item"><a href="#">About</a></li>
      <li class="nav-list-item"><a href="#">Contact Us</a></li>
   </ul>
 </div>
</div>

We need the hamburger to control 2 different states of the drawer when clicked. Think of an element that can switch between 2 states with a single click? A checkbox. We don’t want the checkbox to be visible so we will set its opacity to zero. We need the state of the checkbox to be switched whenever the user clicks the hamburger icon, so we denote the hamburger icon with <label> tag with its “for” attribute pointing to the checkbox. We have also added an aria-label to the checkbox so that people using screen readers know what is the purpose of the checkbox. It is essential for ensuring the right level of accessibility.

.hamburger {
  z-index: 1;
}

.hamburger-container .checkbox {
  opacity: 0;
  cursor: pointer;
  position: absolute;
}

Note: Styles for positioning and appearance purposes are omitted from these snippets.

To ensure visual feedback for keyboard-based navigation, we need to provide an outline if the hamburger icon is focused.

.hamburger-container .checkbox:focus ~ .hamburger {
  /* Not all browsers support outline: auto, so set a sensible fallback outline. */
  outline: 2px solid white;
  outline: auto;
  outline-offset: 4px;
}

/* For newer browsers that do support :focus-visible, hide the outline when the checkbox is not selected with the keyboard. */
@supports selector(:focus-visible) {
  .hamburger-container .checkbox:not(:focus-visible) ~ .hamburger {
    outline: none;
  }
}

By default, our drawer is translated off of the viewport. When the checkbox is in the checked state, we translate it back into the view and add timing to this transition. The same timing and manner will be applicable when the drawer slides again out of the view on a subsequent click on the checkbox.

.drawer {
  transform: translateX(-100%);
  transition: transform 0.5s ease;
}

.hamburger-container .checkbox:checked ~ .drawer {
  transform: translateX(0%);
}

Despite being outside of the view, the content in the drawer is discoverable by keyboard-based navigation. This will confuse users relying on the keyboard for navigating the focus on the screen, as pressing the tab key will provide no visual feedback because of the focus flowing through invisible elements. To fix this, we need to set the visibility of drawer content to hidden when it is outside the view. Elements with visibility set to hidden are not discoverable in keyboard-based navigation. We use a transition to make sure the content doesn’t disappear before it is off the screen.

.hamburger-container .drawer a {
  visibility: hidden;
  transition: visibility 0.5s linear;
}

.hamburger-container .checkbox:checked ~ .drawer a {
  visibility: visible;
}

Now that the sliding behavior is implemented, let’s animate the hamburger to smoothly convert into a cross. Let’s breakdown the requirements of this animation:

.hamburger-container .checkbox:checked ~ .hamburger .slice:nth-child(1) {
  transform: translateY(12px) rotate(45deg);
}

.hamburger-container .checkbox:checked ~ .hamburger .slice:nth-child(2) {
  opacity: 0;
}

.hamburger-container .checkbox:checked ~ .hamburger .slice:nth-child(3) {
  transform: translateY(-12px) rotate(-45deg);
}

Let’s add the transition behavior to the checkbox and the slices.

.hamburger-container .checkbox {
  transition: transform 0.5s ease;
}

.hamburger .slice {
  transition: all 0.5s ease;
}

Thanks to CSS, this transition will be applied in reverse order when the checkbox is clicked. With the same timing and reversed manner, the cross button will transition back to being a hamburger icon.

For low screen resolutions, we will use a media query to expand the drawer to cover the full width of the screen.


@media screen and (max-width: 768px) {
  .drawer {
    width: 100%;
  }
}

With this, the implementation is complete. You can find the full code in the codepen below.

  <div class="hamburger-container">
  <input class="checkbox" type="checkbox" id="hamburger-toggle" aria-label="Toggle Navigation"/>
  <label class="hamburger" for="hamburger-toggle">
    <span class="slice"></span>
    <span class="slice"></span>
    <span class="slice"></span>
  </label>

  <div class="drawer">
    <ul class="nav-list">
      <li class="nav-list-item"><a href="#">Home</a></li>
      <li class="nav-list-item"><a href="#">About</a></li>
      <li class="nav-list-item"><a href="#">Contact Us</a></li>
    </ul>
  </div>
</div>

<div class="content">
  <a href="#">Content</a>
</div>
body {
  height: 100vh;
  width: 100vw;
  margin: 0;
  font-family: sans-serif;
  background: radial-gradient(#8d8888 0%, #101010 100%);
}

.hamburger-container {
  width: max-content;
  position: absolute;
  top: 10px;
  left: 10px;
  color: white;
  transition: transform 0.5s ease;
}

/* We don't need the checkbox to be visible, but we can't set it to display: none because this will break keyboard navigation. Instead set the opacity to 0 and the position to absolute so it doesn't push the rest of the content down */
.hamburger-container .checkbox {
  opacity: 0;
  position: absolute;
}

/* Show an outline when the hamburger is selected using the keyboard. Older browsers don't support :focus-visible, so we will just use :focus here. */
.hamburger-container .checkbox:focus ~ .hamburger {
  /* Not all browsers support outline: auto, so set a sensible fallback outline. */
  outline: 2px solid white;
  outline: auto;
  outline-offset: 4px;
}

/* For newer browsers that do support :focus-visible, hide the outline when the checkbox isn't selected with the keyboard. */
@supports selector(:focus-visible) {
  .hamburger-container .checkbox:not(:focus-visible) ~ .hamburger {
    outline: none;
  }
}

/* Hide any focusable elements in the drawer by default to aid keyboard navigation. We use visibility so it makes the elements unfocusable, but doesn't affect the layout. We can also add a "transition" to visibility, which will make it show instantly when we open the drawer, but take half a second to hide it when we close the drawer. */
.hamburger-container .drawer a {
  visibility: hidden;
  transition: visibility 0.5s linear;
}

/* Make the focusable elements in the drawer visible when it is open. */
.hamburger-container .checkbox:checked ~ .drawer a {
  visibility: visible;
}

.hamburger-container .checkbox:checked ~ .drawer {
  transform: translateX(0%);
}

.hamburger-container .checkbox:checked ~ .hamburger .slice:nth-child(1) {
  transform: translateY(12px) rotate(45deg);
}

.hamburger-container .checkbox:checked ~ .hamburger .slice:nth-child(2) {
  opacity: 0;
}

.hamburger-container .checkbox:checked ~ .hamburger .slice:nth-child(3) {
  transform: translateY(-12px) rotate(-45deg);
}

.hamburger {
  width: 32px;
  height: 32px;
  position: relative;
  display: block;
  transition: transform 0.5s ease;
  z-index: 1;
  cursor: pointer;
  padding-top: 5px;
}

.hamburger .slice {
  display: block;
  width: 100%;
  height: 2px;
  background-color: white;
  transition: all 0.5s ease;
}

.hamburger .slice:not(:first-child) {
  margin-top: 10px;
}

.drawer {
  position: fixed;
  left: 0;
  top: 0;
  height: 100%;
  width: max-content;
  max-width: 100%;
  padding: 22px;
  background: black;
  transform: translateX(-100%);
  transition: transform 0.5s ease;
}

.drawer .nav-list {
  padding: 0;
  list-style: none;
  margin-top: 30px;
  margin-left: 20px;
}

.drawer .nav-list .nav-list-item {
  padding-bottom: 10px;
}

/* Make the drawer full-width on mobile */
@media screen and (max-width: 768px) {
  .drawer {
    width: 100%;
  }
}

.content {
  margin-top: 52px;
  padding: 10px;
}

a {
  color: white;
}

a:hover {
  color: orange;
}
async function demo() {
  function wait(t) {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve();
      }, t);
    });
  }
  
  await wait(400);
  document.querySelector('#hamburger-toggle').focus();
  await wait(400);
  document.querySelector('.content a').focus();
  await wait(400);
  document.querySelector('#hamburger-toggle').focus();
  await wait(400);
  document.querySelector('#hamburger-toggle').checked = true;
  await wait(400);
  document.querySelector('.nav-list .nav-list-item:nth-child(1) a').focus();
  await wait(200);
  document.querySelector('.nav-list .nav-list-item:nth-child(2) a').focus();
  await wait(200);
  document.querySelector('.nav-list .nav-list-item:nth-child(3) a').focus();
  await wait(200);
  document.querySelector('.nav-list .nav-list-item:nth-child(2) a').focus();
  await wait(200);
  document.querySelector('.nav-list .nav-list-item:nth-child(1) a').focus();
  await wait(200);
  document.querySelector('#hamburger-toggle').focus();
  await wait(400);
  document.querySelector('#hamburger-toggle').checked = false;
}

demo();

Conclusion

CSS is very powerful. In this article, we observed how CSS alone can power an accessible hamburger menu without needing any JavaScript.