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?
- Hamburgers: A Social Contract
- Iteration: Mobile-First
- Iteration: Hamburger Absolutism
- Iteration: Accesible Burgers
- Iteration: Good Burger Messy Kitchen
- Design Goals and Burger Behaviour
- Iteration: Better Burger Clean Kitchen
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:
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 onnav-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 theuseCapture
flag (thetrue
at the end of the secondaddEventListener()
)stopPropagation()
withoutuseCapture
would result in the menu never opening.useCapture
withoutstopPropagation()
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.
- when the nav menu is not visible…
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) becamenav-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
- the application state (
- I pushed my strings into
const
s. 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.