How to Code a Hamburger Menu (CSS, JavaScript, React)

Jul 10 2018

Let’s Code

Hamburger Menus: love them or hate them, as a web developer, you’re going to need to know how to build them. In this extensive guide, I’ll explain the CSS behind the hamburger menu, optimizations you should be aware of and potential pitfalls when working with iOS Safari (advice The New York Times, Nike or Apple could learn from themselves!).

If you’re already familiar with building hamburger menus in general but looking for deeper insights, go straight to Step 3: Styling the Menu’s Open State.

Step 1: Widescreen and Narrow View (Menu hidden)

With menu and main content side by side. Lots of ways to have two <div> side-by-side: floats, flexbox, grids. The method featured here is with flexbox, but the choice does not really matter.

.wrapper
.sidebar
.main-content
<div class='wrapper'>
  <div class='sidebar'>
    <!-- NAVIGATION LINKS GO HERE -->
  </div>
  <div class='main-content'>
    <button class='menu-toggle'>Show Menu</button>
    <!-- CONTENT GOES HERE -->
  </div>
</div>
/* ensures children take full-height of viewport */
.wrapper {
  display: flex;
  align-items: stretch;
  min-height: 100vh;
}

.menu-toggle {
  display: none;
}

With just the main content in the viewport.

VIEWPORT
.sidebar (not visible)

.main-content

@media (max-width: 767px) {
  .sidebar {
    display: none;
  }
  .menu-toggle {
    display: initial; /* display the toggle, previously hidden */
  }
}

Step 2: Making the Menu Toggle

There are many ways to code up an element that will trigger the hamburger menu. Here are the reasons to choose each method.

Toggle using the Checkbox Hack

The Checkbox Hack is basically hijacking the browser’s inbuilt ability to manage an on/off state with the <input type="checkbox">. When the input is checked, we can use the pseudo-class of checked and the sibling CSS Selector (input[type="checkbox"]:checked ~ .sidebar) to style elements differently from when the input is unchecked.

Though it is called a “hack”, I think its a “good hack”. It is a pattern that others will recognize, so there’s little of the confusion that typically comes from non-semantic use of web standards. FWIW, it’s how Apple.com toggles their hamburger menu.

Things to consider for this method:

  • If a link in the sidebar leads to another section within the existing page, JavaScript will still be needed to close the menu.

  • You will need full control in determing the hierarchy of all the main DOM elements. The menu or menu’s parent element will have to be a sibling of the input. So will other relevant elements you may want to style differently like the main-content or backdrop overlay. This may not always be feasible if working within a previous design/ legacy code.

  • Relying on the sibling CSS Selector is brittle. Another member on your team may move elements around without realizing that the hierarchy of the input is so tightly coupled to other elements.

  • Recommended for: when it’s easy to communicate within your team, when you have a components system/ separation of concerns by file to convey the relationship between DOM elements, when JavaScript is really not an option

VIEWPORT
.sidebar

.main-content

<div class='wrapper'>
  <!--
    This checkbox will give us the toggle behavior, hidden but functional
  -->
  <input id="hamburger-menu-toggle-1" type="checkbox">

  <!--
    IMPORTANT: Any element that we want to modify when the checkbox state
    changes go here, being "sibling" of the checkbox element
  -->
  <div class='sidebar'>
    <!-- NAVIGATION LINKS GO HERE -->
  </div>
  <div class='main-content'>
    <div>
      <!-- This label is assigned to the checkbox, and will contain the toggle -->
      <label class="button" data-menu-toggle for="hamburger-menu-toggle-1">
        SHOW MENU
      </label>
    </div>
    <!-- CONTENT GOES HERE -->
  </div>
</div>
/* To hide the checkbox */
#hamburger-menu-toggle-1 {
  display: none;
}

/* Styles for the 'open' state, if the checkbox is checked */
#hamburger-menu-toggle-1:checked ~ .sidebar {
  display: initial;
  /* see Step 3 for the rest of the styles */
}

Toggle using Vanilla JavaScript

// Select all the elements that use a specified distinguisher.
const menuTriggers = document.querySelectorAll("[data-menu-toggle]");

// For each element, add a listener for the "click" event.
Array.prototype.forEach.apply(menuTriggers, [
  function(trigger) {
    trigger.addEventListener("click", function(e) {
      e.preventDefault(); // prevent default link behaviour

      // Toggle the sidebar when a click is detected.
      toggleSidebar(); // this function will be defined in a later step
    });
  }
]);

The JavaScript is simple enough. But the obvious drawbacks of this method are:

  • Opening the hamburger menu may be a core task to the user. If the JavaScript fails to run for any reason, you may lose out on potential users.

  • You need a way to ensure that the selector you’ve chosen remains unique.

  • Recommended for: most web apps, when you need multiple triggers, when your menu contains a link to within the currently loaded page

Toggle with React

In React, triggering the menu is as simple as toggling the internal state of a Component. It gets more complicated once we handle the animated transition, but this is the core of the implementation.

class HamburgerMenuWrapper extends Component {
  constructor(props) {
    super(props);
    this.state = { isOpen: false };
  }

  toggleMenu() {
    this.setState({ isOpen: !this.state.isOpen });
  }

  render() {
    return (
      <div class="wrapper">
        {this.state.isOpen && <div class="sidebar">sidebar</div>}
        <button onClick={() => this.toggleMenu()}>Toggle Menu</button>
        <div class="main-content">content</div>
      </div>
    );
  }
}

Step 3: Styling the Menu’s Open State (alias: iOS Safari quirks)

There is only one way to style the hamburger menu’s final, open state that supports iOS Safari. (Presumably, you want a mobile view to work on iPhones!)

If your hamburger menu has no need for scroll… Congratulations! The CSS solution you’re thinking of now will probably work just fine: position the sidebar absolutely out of and into the viewport. Give yourself a pat on the back and skip ahead to the next step!

If your menu has more items than the viewport can display at once, you will have to code around these iOS Safari’s scolling quirks:

  • Scrolling within a position: fixed element can be unpleasant relative to scrolling the body. If done incorrectly (like The New York Times or Pitchfork), it will not scroll smoothly; it will not bounce in that satisfying rubber-band way when reaching the end of scroll.

  • iOS handles switching between scrolling between an element with position and the body poorly. If you overscroll within an absolutely positioned element, iOS may scroll the body instead. If you ever tap beyond the menu or try to move the scrollbar, then try to scroll within the menu again, iOS refuses to scroll at all. Try the hamburger menu on Grab, Viki, Netflix, Amazon or Nike, for example. Even Apple has this problem, although the iPhone SE is the only iPhone that they still sell in 2018 that is short enough to trigger a scroll.

(Let me know if you come across other noteworthy examples and I’ll add them to this list!)

Notice how sometimes iOS scrolls the menu, sometimes it scrolls the body behind the menu? Frustrating!

These heroes of the hamburger menu do it right: Wikipedia, Disney USA… and the one you’re about to build!

Basically, the key thing you must remember about the Menu’s final, open state is this: instead of positioning the menu absolutely, it will be the main content that is positioned once the sidebar is opened. In other words, instead of positioning the menu, position everything else!

/* Arbitrary CSS variable values for explanatory purposes */
:root {
  --sidebar-width: 100px;
  --sidebar-bg-colour: blue;
}

.sidebar {
  display: none;
  position: relative;
  width: var(--sidebar-width);
}

@media (max-width: 767px) {
  .main-content {
    bottom: 0;
    top: 0;

    /* Optional enhancement: if you want the main content to appear to
                        be pushed right by the sidebar */
    left: var(--sidebar-width);
    transition: transform 0.2s;
    transform: translateX(calc(-1 * var(--sidebar-width)));
    /* Why Moving Elements with Translate() Is Better Than Pos:abs Top/left:
                        https://www.paulirish.com/2012/why-moving-elements-with-translate-is-better-than-posabs-topleft/ */
  }

  html.sidebar-is-open .sidebar {
    display: block;
  }

  html.sidebar-is-open .main-content {
    position: fixed;
    overflow: hidden;
    width: 100%; /* prevents resizing from its original full-screen width */

    transform: translateX(0);
  }

  /* Optional enhancement: make the overscroll on iPhone the same colour as the sidebar */
  html.sidebar-is-open body {
    background-color: var(--sidebar-bg-colour);
  }
  .sidebar {
    background-color: var(--sidebar-bg-colour);
  }
}
const documentElement = document.querySelector("html");
const contentElement = document.querySelector(".main-content");
const sidebarElement = document.querySelector(".sidebar");
const sidebarIsOpen = function() {
  return documentElement.classList.contains(".sidebar-is-open");
};

const openSidebar = function() {
  /* The crux of the idea is to add some attribute/class that triggers the
             visual change, you don't necessarily only have to add one class. */
  documentElement.classList.add(".sidebar-is-open");

  /* Tapping beyond the sidebar should close it */
  contentElement.addEventListener("click", handleClickBeyondSidebar);
};

const closeSidebar = function() {
  documentElement.classList.remove(".sidebar-is-open");

  /* Sidebar is closed, so keeping event listener is just a waste of resources
            and source of bugs if openSidebar() is run again */
  contentElement.removeEventListener("click");
};

const handleClickBeyondSidebar = function(e) {
  /* this implementation assumes that all clicks beyond the sidebar close it.
             your sidebar might open modals and other elements not within the sidebar,
             in which case you'd have to make this more precise! */
  if (!sidebarElement.contains(e.target)) {
    closeSidebar();
  }
};

const toggleSidebar = function() {
  sidebarIsOpen() ? closeSidebar() : openSidebar();
};

I haven’t been able to find the relevant bug on WebKit Bugzilla yet, but after some more precise code examples and investigation I’ll be making a bug report - feel free to let me know if you find the right issue!

Step 4: Optional Enhancements

Animating the Hamburger Menu Button ☰

The whole reason it’s called the Hamburger Menu is because of this icon!

Popular icon libraries likely all have their own take on this iconic icon:

You can always use a background image or inline svg too.

But you’ll have to write code to draw it out yourself if you want to animate it. For example, a popular animation is for the three bars to transition into an X to signify that clicking it will close the hamburger menu. (Minimalists like Apple and SuperHi even take this idea to its austere conclusion: since the X only consists of two bars, their hamburger menus only use two bars too!)

To draw something in CSS generally means styling pseudo-elements. (Shoutout: you can see this technique taken to beautiful, inspiring extremes in A Single Div.)

This is the effect the code below creates:

.menu-toggle {
  text-indent: -9999px; /* Keeps the text 'Show Menu' for screen readers but
            hides it from visibility for everyone else. */
  position: relative;

  /* The clickable area. You'll have to adjust the manual positioning of the
            bars if you use padding or change these values */
  width: 26px;
  height: 40px;
}

/* This is how we create two Pseudo elements */
.menu-toggle::before,
.menu-toggle::after {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  display: block;
  width: 26px;
  height: 3px;
  background-color: black;
  transition: transform 0.3s ease-in;
}

/* Position the bars apart however you like, relative to the parent toggle size */
.menu-toggle::before {
  transform: translateY(15px);
}
.menu-toggle::after {
  transform: translateY(23px);
}

.sidebar-is-open .menu-toggle::before {
  transform: translate(0, 20px) rotate(45deg);
}

.sidebar-is-open .menu-toggle::after {
  transform: translate(0, 20px) rotate(-45deg);
}

/* Optional alternative according to Apple: the third bar */
.menu-toggle span.middle-bar {
  display: block;
  width: 26px;
  height: 3px;
  transition: opacity 0.2s;
  opacity: 1;
}

.sidebar-is-open .menu-toggle span.middle-bar {
  opacity: 0;
}

For more inspiration, check out this showcase of CSS hamburger menu animations.

Blocking The Page with An Overlay

When your sidebar is open, you want to prevent your main content from being interactive. Visually, you can represent this with a translucent overlay.

It seems deceptively easy to implement, but be mindful of how you manage your z-index and be wary of unknown unknowns:

  • Make a checklist of your web app’s own stacking contexts and decide when you want the overlay to cover what. For example, you might have a link in your sidebar that triggers a modal and another overlay - what then?

  • You’ll never know what third-party embeds have been added or will be added. How will their z-index compete with your own? Do you want embeds like live chat Intercom icons to appear below or above the overlay?

I can only provide considerations and not code examples for these because the solutions are so specific to your code.

For information about stacking contexts and how z-index really works, check out What No One Told You About Z-Index.

@media (max-width: 767px) {
  .page-overlay {
    background-color: black;
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    top: 0;
    opacity: 0;
    transition: opacity 0.2s;
    visibility: hidden;
  }

  .sidebar-is-open .page-overlay {
    opacity: 0.5;
    visibility: visible;
    z-index: calc(var(--sidebar-z-index) - 1);
  }
}

Step 5: Assembling The Hamburger

Congratulations! If you’ve got this far, you’ve probably got a beautiful, scrolling Hamburger Menu, which means you’ve got a more usable Hamburger Menu than even the New York Times.

Further reading, and other implementations:

Follow on Github for more

If you’d like to learn more CSS/JavaScript by learning how to code popular web components, consider following me on Github for more updates. New blog posts will be accompanied by new repos with sample code!


Jared Tong is a software engineer based in 🇸🇬 Singapore. You should follow him on Github

Questions?

Have a question about this post, web development, or anything else? Ask away on Telegram or in my AMA github.