Guides9 min read·

Mobile menus that work: hamburger, tabs and bottom bars

When your nav stops fitting, pick a mobile menu that works: an accessible hamburger, a bottom tab bar, priority+ or scrolling tabs. Real widths and code.

A mobile menu that works keeps your most important action visible, puts the other links behind a real <button> labelled Menu that opens a plain list, closes with Escape, and gives every link at least 44px of height. Sites with three to five main sections that people switch between often, like shops and web apps, can use a bottom tab bar instead. Content-heavy sites can show what fits and put the rest under "More".

The hard part isn't the pattern, it's picking the width where it kicks in and not breaking the keyboard. Here's both.

When the desktop nav stops fitting

Do the sum for your own header. As an example: a 120px logo, five links averaging 80px, a 130px "Start free" button and 32px gaps between those seven items come to 120 + 400 + 130 + 192 = 842px. Add 32px of padding on each side and you need about 906px.

Now compare that with the widths real screens give your page:

Screen CSS width Does a 906px nav fit?
Galaxy S25 360 No
iPhone 17 Pro 402 No
iPhone 17 Pro Max 440 No
iPad mini (A17 Pro), upright 744 No
iPad Air 11″, upright 820 No
iPad Pro 11″ (M4/M5), upright 834 No
iPhone 17 Pro on its side 874 No
iPad Air 13″, upright 1024 Yes

Two lessons. First, phones aren't the only problem: portrait tablets sit between 744 and 834px and are where half-collapsed navs wrap into two ragged rows. Second, a phone on its side is tablet-wide but only about 337px tall with Safari's bars on an iPhone 17 Pro, so a two-row header eats a big share of the screen (landscape on phones has more).

So collapse where your links stop fitting, not at a device name. For the example above that's below 906px; the code below rounds that up and collapses below 1024px. Responsive breakpoints in 2026 shows where real devices cluster. And leave some slack: translated labels and larger text settings make links longer than your English design.

Pattern 1: the disclosure menu (the "hamburger")

This is the default for most sites, and it's simple to get right if you follow the W3C's disclosure navigation example:

  • A real <button>, not a div or a link. Buttons work with Enter, Space and screen readers for free.
  • aria-expanded says whether the menu is open (true or false); aria-controls points at the list.
  • The list comes straight after the button in the HTML, so Tab moves from the button into the links. No focus tricks needed.
  • Escape closes it and puts focus back on the button.
  • Closed means display: none, so hidden links can't be tabbed to.
  • No role="menu". The ARIA Authoring Practices point out that site navigation doesn't need the app-style keyboard behaviour that role implies.
  • Say "Menu". An icon alone makes people guess. A word costs a few pixels.

The HTML

Put the one-line script in <head> so the menu starts collapsed before the page paints. Without JavaScript, the links simply show as a wrapping row.

<!-- in <head> -->
<script>document.documentElement.classList.add('js');</script>

<header class="site-header">
  <a class="logo" href="/">Acme</a>
  <nav class="site-nav" aria-label="Main">
    <button class="menu-toggle" type="button" aria-expanded="false" aria-controls="main-menu">Menu</button>
    <ul class="menu" id="main-menu">
      <li><a href="/features">Features</a></li>
      <li><a href="/pricing" aria-current="page">Pricing</a></li>
      <li><a href="/customers">Customers</a></li>
      <li><a href="/blog">Blog</a></li>
      <li><a href="/login">Log in</a></li>
    </ul>
  </nav>
  <a class="cta" href="/signup">Start free</a>
</header>

The "Start free" button sits outside the menu on purpose: it stays visible on every screen.

The CSS

.site-header {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px clamp(16px, 5vw, 32px);
}
.logo { margin-right: auto; }

.menu {
  display: flex;
  flex-wrap: wrap;
  gap: 0 32px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.menu a,
.cta {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
}
.menu a[aria-current="page"] { font-weight: 600; }
.menu-toggle { display: none; }

/* Below 1024px: collapse behind the button, but only when JavaScript runs */
@media (max-width: 1023px) {
  .js .menu-toggle {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    min-width: 44px;
    padding: 0 12px;
    font: inherit;
  }
  .js .menu { display: none; }
  .js .menu-toggle[aria-expanded="true"] + .menu {
    display: block;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    max-height: 70vh;
    max-height: 70svh;
    overflow-y: auto;
    padding: 8px clamp(16px, 5vw, 32px) 16px;
    background: #fff;
    border-bottom: 1px solid #ddd;
  }
  .js .menu a { display: flex; }
}

The open state is driven by aria-expanded itself, so the visual state and what screen readers hear can't drift apart. The max-height keeps a long menu scrollable on a phone held sideways.

The JavaScript

const toggle = document.querySelector('.menu-toggle');
const nav = toggle.closest('nav');
const menu = document.getElementById(toggle.getAttribute('aria-controls'));

function setOpen(open) {
  toggle.setAttribute('aria-expanded', String(open));
}

toggle.addEventListener('click', () => {
  setOpen(toggle.getAttribute('aria-expanded') !== 'true');
});

// Escape closes the menu and returns focus to the button
document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape' && toggle.getAttribute('aria-expanded') === 'true') {
    setOpen(false);
    toggle.focus();
  }
});

// Close after a link is chosen (matters for #anchors and client-side routing)
menu.addEventListener('click', (event) => {
  if (event.target.closest('a')) setOpen(false);
});

// Close when focus moves out of the nav, or someone taps outside the header
nav.addEventListener('focusout', (event) => {
  if (event.relatedTarget && !nav.contains(event.relatedTarget)) setOpen(false);
});
document.addEventListener('click', (event) => {
  if (!event.target.closest('.site-header')) setOpen(false);
});

If your menu covers the whole screen instead of dropping down, it's really a dialog. Use a <dialog> opened with showModal(): the browser moves focus into it, closes it on Escape and makes the page behind it inert (MDN on dialog).

Pattern 2: the bottom tab bar

For app-like sites (a shop with Home, Search, Cart and Account, or a dashboard), a bar at the bottom puts the main sections under the thumb. Material Design 3 says navigation bars are for three to five destinations. The maths agrees: on a 360px Galaxy S25, four tabs get 90px each and five get 72px, about the minimum for an icon plus a one-word label.

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

<nav class="tabbar" aria-label="Main">
  <a href="/" aria-current="page"><span class="icon" aria-hidden="true"></span>Home</a>
  <a href="/search"><span class="icon" aria-hidden="true"></span>Search</a>
  <a href="/cart"><span class="icon" aria-hidden="true"></span>Cart</a>
  <a href="/account"><span class="icon" aria-hidden="true"></span>Account</a>
</nav>
.tabbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 10;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  padding-bottom: env(safe-area-inset-bottom, 0px);
  background: #fff;
  border-top: 1px solid #ddd;
}
.tabbar a {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  min-height: 56px;
  font-size: 12px;
}
.tabbar a[aria-current="page"] { font-weight: 600; }

/* Keep the end of the page clear of the bar */
body { padding-bottom: calc(56px + env(safe-area-inset-bottom, 0px)); }

@media (min-width: 768px) {
  .tabbar { display: none; }
  body { padding-bottom: 0; }
}

env(safe-area-inset-bottom) is the strip the home indicator needs. It's only non-zero with viewport-fit=cover in the viewport tag (WebKit's safe area guide); details in safe areas on iPhone. Also remember that Safari's own toolbar lives at the bottom of the screen: your 56px bar comes out of the roughly 718px an iPhone 17 Pro shows on first load. Fixed headers and bottom bars on mobile Safari and Chrome covers how the two interact.

Pattern 3: priority+ ("More" for whatever doesn't fit)

Priority+ shows as many links as fit, in order of importance, and moves the rest into a "More" disclosure at the end of the row. It suits content sites with a handful of important sections and a tail of less important ones, and it keeps the top links one tap away instead of two.

The simple version is CSS: mark the less important links, hide them below a width, and repeat them inside a "More" disclosure built exactly like Pattern 1. The fancy version measures the row with JavaScript (a ResizeObserver on the nav) and moves items that overflow. Either way, "More" is a button with aria-expanded, and the hidden links must be reachable somewhere.

Pattern 4: horizontally scrolling tabs

Shop categories, docs sections and filter chips often work best as one row that scrolls sideways. The row scrolls inside its own box, so the page itself doesn't get wider.

.tabs {
  display: flex;
  gap: 8px;
  margin: 0;
  padding: 0 16px;
  list-style: none;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  scroll-padding-inline: 16px;
}
.tabs > li {
  flex: none;
  scroll-snap-align: start;
}
.tabs a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 14px;
  white-space: nowrap;
}

Let the last visible tab be cut off by the edge: a half-visible item tells people there's more. And scroll the current tab into view on load:

document.querySelector('.tabs [aria-current="page"]')
  ?.scrollIntoView({ block: 'nearest', inline: 'center' });

If the row still makes the page scroll sideways, its parent is probably a flex item that needs min-width: 0 (how to fix horizontal scrolling on mobile).

Every pattern above uses 44px minimum heights. That's Apple's default control size; WCAG 2.2 sets 24×24px as the floor. The details are in 10 responsive mistakes in AI-built websites.

The other common failure is hiding things instead of moving them:

  • Nav hidden with nothing in its place. AI tools love hidden md:flex on the link list. Without a menu button, phone visitors get no navigation at all.
  • The main action buried. Sign up, Book, Cart or Call belongs in the header, not in the menu.
  • Search behind two taps on a shop or docs site. Keep a search button visible.
  • Hover-only dropdowns. Phones don't hover; make sub-menus open on tap with their own button.

Repeat your key links in the footer too. It's where people look when the menu confuses them.

Does your site fit?

Paste your address (or localhost:3000). doesitfit shows it on real screen sizes at once and tells you, bluntly, where it spills.

Free, no sign-up. Or ask Claude, ChatGPT or Codex to check it.

Test it at the widths that break

Check 360px, 402px, 440px, then 744 to 834px for portrait tablets, and a phone on its side. In doesitfit, a nav that neither wraps nor collapses shows up as SPILLING, and Show me where outlines the element sticking out. Turn on Browser bars to see a bottom bar next to Safari's toolbar, and Rotate for landscape.

Then test with a keyboard: narrow your desktop browser below your breakpoint and press Tab, Enter and Escape. Focus should go button, links, out, and Escape should land you back on the button.

To have your AI build it:

Below [1024]px, collapse my header links into a disclosure menu. Use a real button that says Menu, with aria-expanded and aria-controls, at least 44×44px, placed right before the list in the HTML. The list opens below the header, full width, one link per row, each at least 44px tall. Hide it with display: none when closed, close it with Escape (returning focus to the button), when a link is chosen and when focus or a tap goes outside. Don't use role="menu". Keep [the Sign up button] visible outside the menu, keep the desktop nav unchanged, and check it at 360, 402, 744 and 834px wide.

The short version

  • Work out where your links stop fitting. It's often a portrait tablet, not just phones.
  • Disclosure menu: real button, aria-expanded, list right after it, Escape closes, display: none when shut.
  • Bottom bar: three to five destinations, env(safe-area-inset-bottom), body padding so nothing hides under it.
  • Priority+ and scrolling tabs for long lists of sections.
  • Never hide the main action, and give every link 44px of height.

Questions people ask

Is a hamburger menu bad for mobile?

Not when you have more links than fit. The problem is hiding everything behind it. Keep your one or two most important actions, like Sign up or the cart, visible in the header and put the rest in the menu.

What ARIA does a mobile menu button need?

A real button element with aria-expanded set to true or false, and optionally aria-controls pointing to the menu's id. For a list of site links, the W3C's ARIA Authoring Practices advise against role=menu, which is meant for app-style menus.

How many items should a bottom navigation bar have?

Material Design 3 says three to five destinations. On a 360px-wide phone, five tabs leave 72px each, which is about the limit for an icon plus a short label.

How do I stop a bottom bar hiding behind the iPhone home indicator?

Add viewport-fit=cover to your meta viewport tag and give the bar padding-bottom: env(safe-area-inset-bottom). Without viewport-fit=cover the inset is zero and Safari handles the edges for you.

At what width should my navigation collapse?

At the width where your links stop fitting on one line, not at a device name. A logo, five links and a button can easily need 900px, which means collapsing on portrait tablets (744 to 834px wide) as well as phones.

Keep reading