Skip to main content

JON GANDER

MENU

.contains(hamburger)

Created: 2024-Feb-14

Updated: 2024-Feb-14


Cats and cheeseburgers of the internet aside, to understand the state of today’s web experience, the question must be asked:

e.classList.contains('hamburger')

Let us journey together through the creation of modern mobile navigation and its expected behaviours. Are you ready to see how the hamburger (menu) gets made?

If practical, I recommend reading this post on an mobile device era-appropriate relative to 2024. This will allow you to easily reference the hamburger menu implemented at the top of the page.

Index

What’s in a Hamburger?

For those at this point a bit lost, a hamburger menu is the name of the menu style traditionally represented by three horizontal lines, sometimes with small variations. It is common on mobile devices, and makes its way into some desktop scenarios. In case you're viewing this from desktop or if future me has changed the site's navigation tactics, here's what my hamburger menu icon looks like as of writing this:

fake hamburger menu for display purposes only.
        It has the word menu sandwiched in between two white horzontal lines.

Hamburgers: A Social Contract

Why the hamburger menu for mobile, with its pop-out of vertically stacked navigation items? A few reasons:

  • It’s convention
    • (getting the bandwagon fallacy out of the way)
  • A senior developer told me it was a good idea
    • (getting the argument to authority fallacy out of the way)
  • Mobile devices have restricted screen real estate compared to desktops
    • Squishing text is a bad option for readability
    • Shrinking text is a bad option for tappability
  • Mobile phones are most often handled in portait orientation, whereas desktops are usually in landscape.
    • It’s not just a screen size issue, it’s a matter of flow within a space.

None of these reasons are very satisfying, are they. They all have elements of value (even the fallacies), but the truth is, there are A LOT of ways to solve mobile menus, and the thing that pushes the hamburger menu to the forefront is likely inertia. Bandwagon or not, familiarity goes a long way with users.

Iteration: Mobile-First

When I first considered mobile view, I only had two navigation items. I could mercifully leave them side-by-side, hamburgerless, and just shrink the margins a bit. Not quite elegant, but it allowed me to focus on building things I cared about more.

I was considering a hamburger menu, and was encouraged first by a dev friend for aesthetic purposes, and then by necessity when adding a third menu item turned out to be impractical in horizontal format on mobile. This was also roughly the time when I started taking “mobile-first” design seriously, as I had been playing around with CSS @media rules and was beginning to see practical simplification benefits to mobile-first design. Tools in hand, I set out.

Iteration: Hamburger Absolutism

Being less familiar with frontend, one of my first challenges was trying to decide which display style to use for my site. I ultimately decided on display: grid;, as it seemed to simplify some aspects of item placement. Of course, as soon as I tried to turn my navigation vertical, grid betrayed me, pushing all of my other page content down and leaving a disgusting blank space at the top of every page, and I spiralled into despair at how I would have to rewrite my site using flexbox or something else that was “compatible” with vertical menus. As a temporary hack I carefully sized my <h1> titles and near-menu images so that the vertical space was always there, but wasn’t too noticable due to things like adding a margin-top: -5em to an image.

I had yet to reach the point where I had realized how often I would be switching between display types for different page elements, but boy-oh-boy the vertical menu learned me good. absolute-ly you might say. display:absolute was the thing I had been looking for. I tried it and dismissed it due to its odd overlaps with existing content, only to come back to it later. It allowed me to avoid the vertical menu placement blowing up the rest of my formatting, and by adding a slight variation of my background color I was able to get it to stand out enough that it covering other text when visible didn’t look accidental. Later, adding some “glow” (shadow) around it helped it stand out too.

Iteration: Accesible Burger

A friend more adept in frontend than me pointed out that traditional hamburger menus -the ones that are only three horizontal lines- often cause issues for visually-impaired individuals, as screen readers can really only describe the menu visualization as horizontal lines. He suggested adding “Menu” as the top line, though I compromised and made it the middle name to maintain “hamburgerness”. Still, a great suggestion.

For a personal site with a small readership, I don’t feel obligated to prioritize accessibility, but hey, if I can do it without much effort, why wouldn’t I?

Related TODO: check out the The A11Y Project for design philosophy of making the internet more accessible, and for making accessibility more implementable for devs.

Iteration: Interactive Burger

The first version of the hamburger menu interactivity toggled a nav-links-hidden class that was attached to the box around the menu items. As soon as it was toggled, the box would cover up the hamburger icon, meaning you couldn’t tap to close it. That only lasted a few minutes, but it was a fun mistake.

This basic interactivity was a good start, and I again shelved further interaction while I worked on other pieces of the site, but I knew it wasn’t complete. It wasn’t the behaviour I knew users would expect. It wasn’t the behaviour I expected.

Iteration: Good Burger, Messy Kitchen

To be fair to myself, the behaviour was close to expected. There was one big part I knew was missing, which was being able to “tap away” from the menu by tapping anywhere else on the page. This challenge gave me a great reason to dive deeper into EventTarget, EventListener, and event flows. I cobbled together a monstrosity:

document.querySelector('.hamburger').addEventListener('click', (event) => {
    document.querySelector('.nav-links').classList.remove('nav-links-hidden');
});

document.body.addEventListener('click', (event) => {
    if(!document.querySelector('.nav-links').classList.contains('nav-links-hidden')) {
        document.querySelector('.nav-links').classList.add('nav-links-hidden');
        event.stopPropagation();
    }
}, true);

From a user perspective, this was a step in the right direction, and for that reason (and because I am the sole project contributor), I pushed to production.

Now, there’s nothing abjectly wrong with this code, but I’d like to point out a few things:

  • remove() being called on nav-links-hidden is a double negative. Negatives are more difficult to reason about, and in my opinion, this is code that started to show that: the code suggests complicated behaviour, even if my mental model of the ideal behaviour was far more simple.
  • I was forced to use stopPropagation() AND set the useCapture flag (the true at the end of the second addEventListener())
    • stopPropagation() without useCapture would result in the menu never opening.
    • useCapture without stopPropagation() would result in the menu staying open if the hamburger was tapped while already open, negating the “toggle” of it.
  • While I don’t have any concrete examples to draw from, the idea of having a stopPropagation() call in such a broad EventListener seemed like it could easily cause problems if I tried to add more interactivity in the future.

There was also one minor flaw my code allowed that I didn’t yet know how to fix: when a user clicked a navigation item, the menu would close, even if the user hadn’t yet been directed to the new page. This wouldn’t cause most folks issues, but it gave me vibes of “did I actually click it or not?” and after doing some experimentation with slower (throttled) networks, the impression of bugginess got a lot worse.

Design Goals and Burger Behaviour

The experience of the menu abruptly closing prior to navigating to the new page finally pushed me to write down exactly what behaviours I wanted. One of the benefits of the ubiquity of hamburger menus is that I didn’t have to poll users for feedback to understand what behaviours they would expect. My own experience with hamburger menus might not be comprehensive, but I was able to write a list I felt confident covered the important stuff.

Behaviours:

  • on desktop, the nav items should never be hidden
  • on mobile…
    • when the nav menu is not visible…
      • then clicking on the hamburger icon should show the nav.
      • then clicking anywhere other than the hamburger should do nothing.
    • when the nav menu is visible…
      • then clicking a nav item should leave the nav visible while navigation takes place.
      • then clicking outside the nav menu should hide the nav menu.

Behaviours set out in this manner doesn’t need to hold the exact same shape as the code that implements the behaviour, but the closer it is, the better it is for code maintainability. This “given-when-then” format is also one I’ve found quite useful for unit tests in other projects.

Iteration: Better Burger, Clean Kitchen

Armed with more formalized behaviours, I went back to massage the code to both fix the remaining behavioural gap of which I was aware, and to have the code better match my mental model. Here’s where I landed:


const click = 'click';
const hamburger = 'hamburger';
const navLinks = 'nav-links';
const mobileNavVisible = 'mobile-nav-visible';
const navLinksClasses = document.querySelector('.' + navLinks).classList;

document.querySelector('.' + hamburger).addEventListener(click, () => {
    navLinksClasses.toggle(mobileNavVisible);
});

document.body.addEventListener(click, (event) => {
    /*
    I realize this is a premature optimization, but where the listener is so
    broad, encapsulating its concepts can avoid a LOT of future headaches,
    and as a bonus, the intent is now captured with a method name.
    */
    handleHideNavMenu(event);
});

function handleHideNavMenu(event) {
    const isMobileNavVisible = navLinksClasses.contains(mobileNavVisible);
    const clickedHamburger = composedPathHasClass(event, hamburger);
    const clickedNavLinks = composedPathHasClass(event, navLinks);

    if (!isMobileNavVisible || clickedHamburger || clickedNavLinks) {
        return;
    }

    navLinksClasses.remove(mobileNavVisible);
}

function composedPathHasClass(event, className){
    return event.composedPath().some(
        (pathEvent) => pathEvent.classList && pathEvent.classList.contains(className)
    );
}

Here are the differences I like:

  • nav-links-hidden (on by default) became nav-links-visible (added upon user tapping the hamburger icon). This change gets a lot closer to matching code concepts to user behaviour.
  • The hamburger menu does one thing consistently: it toggles the menu. It doesn’t care what state the menu is in.
  • The body can be clicked anywhere, but using the composedPath() allows me to determine what is “under” the body in the particular place that was tapped, and ignore taps in cases where:
    • the application state (!isMobileNavVisible) doesn’t require action
    • a specialized listener (clickedHamburger) already handles behaviour
    • a specific behaviour (clickedNavLinks) warrants inaction
  • I pushed my strings into consts. Not necessary, but it works well to indicate what the file cares about up-front, and reduces errors due to spelling or renaming.
  • Removing duplication with navLinksClasses helps minimize noise, as it was doing multiple levels of selection, and happening multiple times.
  • No more useCapture!
  • No more stopPropagation()!

composedPath() is the real hero here, and I’m surprised it’s not a more common solution for this issue. From what I saw while trying to find a solution for “click anywhere to hide hamburger menu”, one of the common approaches is to use shared state (a global isMenuVisible=false flag in JS), and all the rest of the approaches I saw relied on useCapture and stopPropagation(). Possibly in the future I’ll learn why that strategy might be better, but for now I feel like I’ve made a small contribution to society, an iterative hamburger innovation, and I have composedPath() (and Mozilla documentation) to thank.