Responsive, Scalable Hamburger Menu in Accessible CSS

I avoided hamburger menus for the better part of the past decade.

This time, I had no better idea. The result is the new mobile navigaton for zettelkasten.de.

Also, it seems like the UI component sticks with people, and is not just a weird fad anymore. It’s too old for being trendy.

So I draw inspiration from these resources I found that showed how to use modern CSS to style dropdown menus on websites:

The last resource is for dropdown submenus, not hamburgers, but it looks great and was a very useful resource.

I shared my solution on CodePen: https://codepen.io/ctietze/pen/LEPrdRM?editors=1100

See the Pen Pure CSS, Responsive, Flexible Height Hamburger Menu Toggle by Christian (@ctietze) on CodePen.

Use the 200px and the clamped version by tweaking the CSS, and see for yourself!

HTML

The HTML is really simple. To make the menu accessible, you need to have an interactible element; people seem to choose checkboxes. It puts the toggle state into the DOM, which looks fine to me:

<nav id="navigation">
  <input type="checkbox" id="hamburger-toggle" aria-label="hamburger" aria-controls="menu" aria-expanded="false" />
  <label for="hamburger-toggle" id="hamburger" aria-hidden="true">
    <span class="slice"></span>
    <span class="slice"></span>
    <span class="slice"></span>
  </label>

  <ul id="menu">
    <li><a href="#">Menu Item 1</a></li>
    <li><a href="#">Menu Item 2</a></li>
    <li><a href="#">Menu Item 3</a></li>
  </ul>
</nav>

The three .slice classes will be used to draw the hamburger in plain CSS, and animate morphing 3 slices into two 45º lines for an “X” shape.

The #hamburger-toggle is the checkbox holding the state, while the #hamburger is the visible ‘icon’.

CSS

I lamented that most approaches were using fixed pixel offsets above. So I fiddled with a combination of vertical margins between the slices and relative offsets until I got something that worked with relative % values.

In practice, I actually use a clamped, responsive value between 16px and 32px, depending on available space. But you could scale this to hundreds or thousands of pixels height and it would still align perfectly.

:root {
  --hamburger-slice-color: black;
  --size: clamp(16px, 10vmin, 32px); /* Sensible value */
  --size: 200px; /* 👈 A value to show that the math is correct */
}

#navigation {
  position: relative;
}
#menu {
  visibility: hidden;
  position: absolute;
  top: 100%;
  left: 0;
}
#hamburger-toggle {
  opacity: 0;
  cursor: pointer;
  position: absolute;
}
#hamburger {
  display: block;
  padding: 0.5rem;
  cursor: pointer;
  width: var(--size);
  height: var(--size);
  box-sizing: content-box;
  transition: transform 0.2s ease;
}
#hamburger .slice {
  --slice-height: 2px;
  display: block;
  position: relative;
  width: 100%;
  height: var(--slice-height, 2px);
  border-radius: var(--slice-height);
  transition: all 0.2s ease;
  background-color: var(--hamburger-slice-color);
  opacity: 90%;
}
#hamburger .slice {
  margin-top: 22%; /* Fallback to produce something hamburger-ish if calc is not available */
  margin-top: calc(33% - var(--slice-height));
  top: calc((33% - var(--slice-height)) / -2);
}

This sets up every slice to be 1/3 or 33% apart from the previous one. That will put the last slice at the bottom edge, and the first slice at 33% offset from the top.

To mitigate this and visually center all three slices, I use top to offset all slices by 1/6th of the total available height towards the top again.

To not ‘hang’ the slice on that position but vertically center it around it, the calculcation is actually (33% - var(--slice-height)) / -2).

Now the “X”-mark to close the menu again is achieved by hiding the center slice, and rotating the top and bottom by 45º. I found the following calculations through geometric experiments and then expressed the resulting values more cleanly:

#hamburger-toggle:checked ~ #hamburger .slice:nth-child(1) {
  top: calc(50% - 33% + var(--slice-height) / 2);
  transform: rotate(45deg);
}
#hamburger-toggle:checked ~ #hamburger .slice:nth-child(2) {
  opacity: 0;
}
#hamburger-toggle:checked ~ #hamburger .slice:nth-child(3) {
  top: calc(-50% + var(--slice-height) / 2);
  transform: rotate(-45deg);
}

Bonus: JavaScript

Pure CSS, but then use JavaScript? Why? How?

It’s used to hide the dropdown menu when clicking anywhere outside of it. Since we’re storing the state in a checkbox, clicking outside the checkbox won’t uncheck it – unlike, say :hover or :focus-inside would detect that you moved or clicked or tabbed elsewhere.

The solution is a simple comparison of a click event’s path with the nav element itself. If the clicked HTML element is not part of the navigation element subtree, it automatically uncheck the hamburger toggle:

const nav = document.getElementById("navigation");
const hamburger = document.getElementById("hamburger-toggle");

window.addEventListener("click", (event) => {
  const eventPath = event.composedPath();
  const isTargeted = eventPath.includes(nav);
  if (!isTargeted) {
    hamburger.checked = false;
  }
});

Missing Accessibility Features

The aria-expanded attribute of the hamburger, and the aria-hidden attribute of the menu need to be set via JavaScript, too. So the actual JS is a bit longer:

const nav = document.getElementById("navigation");
const hamburger = document.getElementById("hamburger-toggle");
const menu = document.getElementById("menu");

const updateHamburgerARIA = () => {
  hamburger.setAttribute("aria-expanded", hamburger.checked ? "true" : "false");
  menu.setAttribute("aria-hidden", !hamburger.checked ? "true" : "false");
};

hamburger.addEventListener("change", updateHamburgerARIA);

window.addEventListener("click", (event) => {
  const eventPath = event.composedPath();
  const isTargeted = eventPath.includes(nav);
  if (!isTargeted) {
    hamburger.checked = false;
    updateHamburgerARIA();
  }
});

Note that the hamburger.checked boolean value isn’t forwarded to setAttribute because apparently the attribute value ought to be a string, not a boolean, and we should not rely on HTML doing the mapping or something.