All WordPress HTML Templates Forms & Webhooks AI & Tools
HTML Templates

Navigation Patterns: Mega Menus, Drawers and Mobile Reality

A practitioner’s guide to website navigation in 2026: accessible mega menu markup, hover intent, mobile drawers, scroll locking, inert and sticky header

A real working moment illustrating the theme of an article about website navigation. Wide 16:9 banner, one strong focal point, magazine editorial quality, authentic and unstaged.

A client once sent us a screenshot of their desktop header with eleven top-level items, four of which opened mega panels, one of which contained a nested dropdown inside the panel. Average time to find the pricing page in a five-person test: nineteen seconds. On mobile the same tree collapsed into an accordion three levels deep, which nobody ever reached the bottom of.

Website navigation is where information architecture stops being a diagram and starts being a thing people poke at with a thumb while walking. Most of the failures we get called in to fix aren’t styling failures. They’re structural decisions that were made in a sitemap document and then handed to a front-end dev as “build a mega menu”.

So here’s what we actually ship in 2026, what we’ve stopped shipping, and the specific bits that break in ways the listicles never mention.

Key Takeaways

  • Open mega panels on click, not hover. Hover-only menus fail WCAG 1.4.13, misfire constantly on trackpads, and are unusable on hybrid touch laptops. If you keep hover, gate it behind (hover: hover) and (pointer: fine) and add a 120ms to 180ms intent delay.
  • Do not use role="menu" or role="menubar" for site navigation. Those roles promise application-style arrow-key semantics that your markup almost certainly does not deliver. A <nav> with a <ul>, buttons with aria-expanded, and real links is correct and more predictable.
  • A mega menu above roughly 40 to 50 destinations needs a search field inside it. Past that count, browsing is slower than typing and your analytics will show it.
  • Mobile drawers need four things people forget: inert on the background, overscroll-behavior: contain, scroll position restoration, and a history entry so the Android back gesture closes the drawer instead of leaving the page.
  • Sticky headers cost real vertical space. On a 667px viewport a 64px sticky header is nearly 10% of the screen, permanently. Measure it before you commit, and set scroll-padding-top so anchor links don’t land under it.

The widget is not the problem

Every mega menu we’ve been asked to build has been a request to avoid a prioritisation argument. Eleven top-level items exist because eleven departments each wanted one. The menu becomes the org chart, rendered in CSS Grid.

Before you write any markup, do the card sort. Ten participants is enough to expose the obvious wrongness. Then apply a hard rule: five to seven top-level items, and every one of them has to be a word a customer would use unprompted. “Solutions” is not that word. Neither is “Resources”, though we lose that fight about half the time.

The useful test is what we call the two-step rule. Any page that matters commercially should be reachable in two interactions from the home page: one to open a panel, one to click the link. If a page needs three, it either doesn’t matter that much or your grouping is wrong. Sites that genuinely can’t hit that, large ecommerce catalogues, universities, government portals, aren’t failing. They’re the specific case where a mega menu plus in-menu search is the right answer rather than a workaround.

Mega menu markup that holds up

The panel should be a sibling of the trigger, the trigger should be a <button>, and the panel’s visibility should be driven by the hidden attribute rather than display: none from a class. One source of truth.

<nav class="primary-nav" aria-label="Main">
  <ul class="nav-list">
    <li class="nav-item has-mega">
      <button type="button" class="nav-trigger"
              aria-expanded="false" aria-controls="mega-products">
        Products
        <svg class="chevron" aria-hidden="true" focusable="false">...</svg>
      </button>

      <div class="mega-panel" id="mega-products" hidden>
        <div class="mega-grid">
          <div class="mega-col">
            <!-- p, not h3: headings inside nav pollute the screen reader
                 heading list on every single page of the site -->
            <p class="mega-col-title" id="mp-build">Build</p>
            <ul aria-labelledby="mp-build">
              <li><a href="/templates/">HTML templates</a></li>
              <li><a href="/blocks/">Block library</a></li>
            </ul>
          </div>
          <div class="mega-col mega-featured">
            <a href="/whats-new/" class="mega-card">
              <img src="/img/release.webp" width="320" height="180"
                   alt="" loading="lazy">
              <span>What's new in 3.4</span>
            </a>
          </div>
        </div>
      </div>
    </li>
  </ul>
</nav>

Two details earn their keep here. The alt="" on the promo image, because the adjacent text already says what the link does and a screen reader user doesn’t need it twice. And position: static on the list item, so the panel can span the full header width instead of being trapped inside a 90px-wide <li>.

.primary-nav { position: relative; }
.nav-item.has-mega { position: static; } / panel measures against .primary-nav /

.mega-panel {
  position: absolute;
  inset-inline: 0;
  top: 100%;
  background: var(--header-bg);
  box-shadow: 0 16px 40px rgb(0 0 0 / .12);
}

.mega-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
  gap: 2rem;
  max-width: 1200px;
  margin-inline: auto;
  padding: 2rem 1.5rem;
}

/* Animate transform/opacity only. Animating height here caused a
   visible 40ms layout hitch on a 2019 MacBook in our own testing. */
.mega-panel:not([hidden]) { animation: mega-in 140ms ease-out; }
@keyframes mega-in {
  from { opacity: 0; transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
  .mega-panel:not([hidden]) { animation: none; }
}

If you’re animating anything heavier than that, read our notes on micro-interactions and scroll animation without wrecking performance first. Menus are the easiest place in a layout to accidentally trigger a full reflow sixty times a second.

Hover is a hint, not an input

Hover-triggered panels fail in four ways we see on real sessions. The cursor crosses a trigger on its way to something else and a 600px panel slams open. The user’s hand is on a trackpad, so there’s no “hovering intent” at all, just a coordinate. The device is a Surface or an iPad with a keyboard, where the pointer exists sometimes. And WCAG 1.4.13 requires hover-revealed content to be dismissible without moving the pointer, which almost nobody implements.

Click-to-open solves all four and costs you one extra interaction on desktop. We’ve A/B’d it on two commerce clients; neither saw a measurable drop in menu engagement. Keyboard users get the same behaviour as mouse users, which is the actual win.

If the brief insists on hover, do it like this.

const nav = document.querySelector('.primary-nav');
const fine = matchMedia('(hover: hover) and (pointer: fine)');
let openTimer, closeTimer, current = null;

function setOpen(trigger, open) {
  const panel = document.getElementById(trigger.getAttribute('aria-controls'));
  trigger.setAttribute('aria-expanded', String(open));
  panel.hidden = !open;
  current = open ? trigger : null;
}

nav.addEventListener('click', (e) => {
  const trigger = e.target.closest('.nav-trigger');
  if (!trigger) return;
  const open = trigger.getAttribute('aria-expanded') === 'true';
  if (current && current !== trigger) setOpen(current, false);
  setOpen(trigger, !open);
});

if (fine.matches) {
  nav.addEventListener('pointerover', (e) => {
    if (e.pointerType !== 'mouse') return;      // ignore touch-generated hover
    const trigger = e.target.closest('.nav-trigger');
    if (!trigger) return;
    clearTimeout(closeTimer);
    // 140ms of intent: long enough to ignore a cursor passing through
    openTimer = setTimeout(() => {
      if (current && current !== trigger) setOpen(current, false);
      setOpen(trigger, true);
    }, 140);
  });

  nav.addEventListener('pointerleave', () => {
    clearTimeout(openTimer);
    // 250ms grace so a diagonal mouse path into the panel doesn't close it
    closeTimer = setTimeout(() => current && setOpen(current, false), 250);
  });
}

// WCAG 1.4.13: dismissible without moving the pointer
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && current) { const t = current; setOpen(t, false); t.focus(); }
});
document.addEventListener('pointerdown', (e) => {
  if (current && !e.target.closest('.nav-item.has-mega')) setOpen(current, false);
});

Note what’s missing: no role="menu", no arrow-key hijacking, no focus trap. Tab moves through the links in DOM order, which is what a user scanning a page of links expects. Focus trapping a desktop dropdown is the single most common over-engineering we delete from inherited codebases.

Mobile navigation and the four things everyone forgets

The off-canvas drawer won, and it won for good reasons: it’s familiar, it doesn’t fight the URL bar, and it gives you room for a two-level accordion without a scrollbar war. Mobile navigation still breaks constantly, though, and it’s nearly always the same four omissions.

Background scroll and the iOS problem

overflow: hidden on <body> is not enough on iOS Safari. The page jumps to the top when you close the drawer and the rubber-band scroll leaks through. You need to freeze the position and put it back.

let lockedAt = 0;

function lockScroll() {
  lockedAt = window.scrollY;
  document.body.style.position = 'fixed';
  document.body.style.top = -${lockedAt}px;
  document.body.style.inlineSize = '100%';
}

function unlockScroll() {
  document.body.style.position = '';
  document.body.style.top = '';
  document.body.style.inlineSize = '';
  window.scrollTo(0, lockedAt);   // instant, not smooth, or it looks broken
}

function openDrawer() {
  lockScroll();
  drawer.hidden = false;
  document.getElementById('main').inert = true;   // hides bg from AT + tab order
  drawer.querySelector('.drawer-close').focus();
  history.pushState({ drawer: true }, '');        // Android back closes it
}

function closeDrawer() {
  drawer.hidden = true;
  document.getElementById('main').inert = false;
  unlockScroll();
  toggleBtn.focus();
}

addEventListener('popstate', () => { if (!drawer.hidden) closeDrawer(); });

Pair that with overscroll-behavior: contain on the drawer itself so scrolling to the end of the menu doesn’t start scrolling the document behind it. One line, fixes a bug that takes an hour to diagnose.

.drawer {
  position: fixed;
  inset: 0 0 0 auto;
  inline-size: min(88vw, 23rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding-block-end: env(safe-area-inset-bottom);  / home indicator /
}
.drawer a, .drawer button { min-block-size: 44px; } / thumb target /

Depth, not width, is what kills mobile menus

Two levels is the limit. A top-level accordion that expands into a flat list of links is fine. A third level, where the user has to expand twice and then scroll, is where our session recordings show people bailing out and using the browser back button instead. If your IA truly needs three levels on mobile, show a full-screen panel that slides in and replaces the previous one, iOS Settings style, with a labelled back control. Don’t nest accordions.

Put search above the menu, not inside it

On any site with more than about fifty destinations, the search field is the primary navigation on mobile and the menu is the fallback. Order it accordingly. Keep the input a real <input type="search"> with a visible label or a properly associated one; the same rules that apply to styling forms without breaking accessibility apply to a 40px search box in a drawer.

The sticky header budget

Sticky headers are the default now and that’s mostly fine, but they’re not free. Pick a height and defend it: 56px to 64px on mobile, 72px to 80px on desktop. Anything taller and you’re renting screen space from your own content.

Three things you need with any sticky header:

  • scroll-padding-top: 5rem on the :root, or every in-page anchor link lands with its target heading hidden behind your header. This matters more than people expect on one-page sites, where anchors are the navigation.
  • A real check against WCAG 2.2’s Focus Not Obscured criterion. Tab through the page with the header stuck and watch whether the focus ring on the first item below the fold gets clipped. It usually does.
  • If you shrink or hide the header on scroll, use a threshold of at least 80px to 100px of scroll delta. Reacting to every pixel gives you a header that flickers on a trackpad.

Hide-on-scroll-down, show-on-scroll-up is a pattern we’ve largely stopped recommending for content sites. It wins back space, but it means the navigation isn’t where the user left it. On long articles people scroll up a few pixels for all sorts of reasons. Keep it for reading-heavy apps; skip it for marketing sites.

Popover, anchor positioning and what you can actually use

The Popover API is genuinely usable now. popover plus popovertarget gives you light-dismiss, Escape handling and top-layer stacking for free, with no z-index archaeology. It’s supported across Chrome, Edge, Safari and Firefox and has been for long enough to drop the polyfill on most projects. For a simple account dropdown or a language switcher, it’s less code than anything you’d write by hand.

CSS anchor positioning is the other half of the story and it’s not there yet. Chromium shipped it in 125; Safari and Firefox support has lagged well behind, so if your menu layout depends on it you need a fallback path anyway. For a full-width mega panel you don’t need it at all, since absolute positioning against the header does the job. Use anchor positioning for things that float next to a trigger, and treat it as progressive enhancement.

One thing worth saying plainly: a well-built navigation is a surprising amount of code. If you’re starting from a template, check the nav before you check the hero sections, because that’s the component you’ll be editing every week for the next three years. The Canvas header variants exist in roughly a dozen shapes precisely because every project wants a slightly different one, and retrofitting a drawer into a nav that wasn’t built for it is a worse day than you think. Our guide on how to evaluate an HTML template has the longer checklist.

Frequently Asked Questions

Should a mega menu open on hover or on click?

Click, in almost every case. Hover-only panels can’t satisfy WCAG 1.4.13’s dismissible requirement without extra work, they misfire on trackpads, and they’re meaningless on hybrid touch laptops. If you support hover as an enhancement, gate it behind a (hover: hover) and (pointer: fine) media query and add a 140ms intent delay before opening.

Is a hamburger icon still acceptable on desktop?

Only if the site genuinely has very few destinations or is a tool rather than a marketing site. On desktop you have the horizontal space to show five to seven labels, and visible labels measurably outperform a hidden menu for discovery. Hiding desktop navigation behind an icon is usually a design preference being paid for with conversions.

How many items is too many for a mega menu?

Past about 40 to 50 links, browsing becomes slower than typing, so add a search field inside the panel. Past roughly 80, the mega menu should become a category landing page instead, with the menu linking to that page rather than trying to reproduce it. Watch your in-menu click distribution: if the bottom third of a column never gets clicked, it isn’t navigation, it’s decoration.

What does the inert attribute actually do for a drawer?

It removes everything inside the inert element from the tab order and from the accessibility tree, so a keyboard or screen reader user can’t wander into the page behind an open drawer. It’s supported in all current evergreen browsers and replaces the old approach of manually setting tabindex="-1" on every focusable background element. Apply it to your main content wrapper, not to <body>, or you’ll make the drawer inert too.

Do I need ARIA menu roles for site navigation?

No, and adding them usually makes things worse. role="menu" and role="menubar" signal application-style widgets where arrow keys move focus and Tab exits the whole menu, which is not how a list of page links behaves. A <nav aria-label="Main"> containing a list of links, with <button aria-expanded> for anything that opens a panel, communicates everything assistive tech needs.

Where to start on Monday

Pick one thing. Open your own site on a mid-range Android phone, on cellular, and try to reach your three highest-value pages with one hand. Count the taps and the seconds. Then tab through the desktop header with the keyboard and press Escape on an open panel.

If the mobile path takes more than three taps or Escape does nothing, you don’t have a design problem yet. You have a bug list. Fix the bug list first; the redesign argument can wait until the thing you already shipped actually works.

accessible mega menu markup aria-expanded dropdown navigation hover intent menu delay javascript inert attribute off-canvas menu mobile navigation drawer scroll lock popover api navigation dropdown sticky header scroll-padding-top website navigation