Blog

Accessible Mega Menus: Disclosure Pattern Code for Ecommerce Nav

TestParty
TestParty
October 24, 2026

Last updated: October 24, 2026

An accessible mega menu uses a real `<nav>` landmark, the W3C's disclosure pattern rather than the ARIA menubar pattern, and a button that manages `aria-expanded` and `aria-controls` for every panel. That combination lets keyboard and screen reader users open, browse, and close it exactly like a mouse user does. This guide covers the failure patterns audits flag most, then working code for structure, disclosure, keyboard flow, hover rules, and the mobile drawer.

Key numbers: WCAG 2.2's 4.1.2 Name, Role, Value and 2.4.7 Focus Visible are the two criteria mega-menu builds fail most often, out of 55 criteria in force at Levels A and AA (W3C). 95.9% of the top one million home pages carried a detectable WCAG failure in 2026, averaging 56.1 errors per page (WebAIM Million, 2026). As of August 2026, TestParty has remediated 35 million-plus accessibility issues across 100+ brands, and navigation and menu components rank among the most common sources of violations in TestParty's Shopify audits (TestParty internal data). Shopify's Theme Store requirements cover only 16–22% of WCAG success criteria, and stock Dawn ships 30–100 detectable violations before a merchant customizes a single nav item β€” detection on those custom builds runs the usual 60–70% automated / 30% manual split (TestParty internal data, August 2026).

Why do mega menus fail accessibility audits?

Mega menus fail audits because they're built to look like navigation but behave like decoration: hover-only reveal locks out keyboard users, focus never reaches the panel that opened, and div-based triggers carry no role a screen reader can announce.

Five patterns cover nearly every mega-menu finding in TestParty's audits. Hover-only reveal fails 2.1.1 (Keyboard) β€” a `<div>` opening only on `:hover` gives a keyboard user no way to trigger it. A panel opening without moving focus into it, or rendering over the element still focused, fails 2.4.3 (Focus Order) and 2.2's 2.4.11 (Focus Not Obscured Minimum). Div-soup triggers fail 4.1.2 (Name, Role, Value), since a `<div onmouseover>` carries no role, state, or key handling by default. No Escape and no click-outside close traps a user inside an open panel, and a panel vanishing the instant the pointer strays off path fails 1.4.13 (Content on Hover or Focus). Mobile drawers compound all four at once β€” on the nav view most shoppers actually use.

What's the right semantic structure for ecommerce nav?

Ecommerce nav needs a `<nav>` landmark with an `aria-label`, a plain `<ul>`/`<li>` list of real links, and β€” for any item that both navigates and opens a panel β€” a split button separating the two actions into two controls.

That split button solves the recurring trigger question: "Women" is a link to `/collections/women` and a way to open the Women panel. A JS-driven `<div>` or `<a href="#">` breaks navigation for anyone who just wants the category β€” two adjacent controls fix it.

<!-- Before: fails WCAG 4.1.2 (Name, Role, Value) and 2.1.1 (Keyboard) β€” the <div> trigger
     carries no role, so a keyboard user can't tell whether Enter will navigate or open a panel -->
<nav class="main-nav">
  <ul>
    <li>
      <div class="nav-item" onclick="toggleMegaMenu(this)">Women</div>
      <div class="mega-panel"><!-- links --></div>
    </li>
  </ul>
</nav>

<!-- After: fixes 4.1.2 and 2.1.1 β€” <nav> names the region, and a split button separates
     "go to Women" from "open the Women panel" into two real controls -->
<nav aria-label="Main" class="main-nav">
  <ul>
    <li class="has-submenu">
      <a href="/collections/women">Women</a>
      <button type="button" class="submenu-toggle" aria-expanded="false"
              aria-controls="panel-women" aria-label="Women, submenu">
        <svg aria-hidden="true" focusable="false"><!-- chevron --></svg>
      </button>
      <div id="panel-women" class="mega-panel" hidden>
        <h2 class="visually-hidden">Women</h2>
        <!-- sub-sections use h3, so the link list stays navigable by heading -->
      </div>
    </li>
  </ul>
</nav>

Keep a heading β€” even hidden visually β€” at the top of every panel and sub-column, so twelve link lists don't become twelve indistinguishable stops for a screen reader user skimming by heading. Same 1.3.1 (Info and Relationships) logic as fieldset/legend groups in product filters.

How do you build the disclosure pattern correctly?

Site navigation belongs to the W3C APG's disclosure pattern, not the ARIA menubar pattern β€” menubar is built for app-style menus like a desktop File/Edit bar, and forcing it onto storefront nav breaks the ordinary Tab-and-Enter behavior shoppers expect.

Menubar (`role="menubar"`, `menuitem`) replaces Tab movement with roving tabindex and arrow-key-only navigation β€” only one item sits in the Tab sequence, and arrow keys move a virtual cursor between the rest. That fits a closed app menu, not a storefront where every link should act like a link, and a true `menuitem` can't cleanly hold the layout a mega-panel needs anyway. The disclosure pattern skips all of it β€” a plain button toggles a plain panel, every link stays Tabbable.

<script>
document.querySelectorAll('.submenu-toggle').forEach((btn) => {
  const panel = document.getElementById(btn.getAttribute('aria-controls'));
  btn.addEventListener('click', () => {
    const wasOpen = btn.getAttribute('aria-expanded') === 'true';
    closeAllPanels();
    if (!wasOpen) openPanel(btn, panel);
  });
});

function openPanel(btn, panel) {
  btn.setAttribute('aria-expanded', 'true'); // fixes WCAG 4.1.2 β€” state is announced
  panel.hidden = false;
}
function closeAllPanels() {
  document.querySelectorAll('.submenu-toggle[aria-expanded="true"]').forEach((btn) => {
    btn.setAttribute('aria-expanded', 'false');
    document.getElementById(btn.getAttribute('aria-controls')).hidden = true;
  });
}

document.addEventListener('keydown', (e) => {
  if (e.key !== 'Escape') return;
  const openBtn = document.querySelector('.submenu-toggle[aria-expanded="true"]');
  if (openBtn) { closeAllPanels(); openBtn.focus(); } // fixes 2.1.2 β€” Escape closes, focus returns
});

document.addEventListener('click', (e) => {
  if (!e.target.closest('.has-submenu')) closeAllPanels(); // click outside closes it
});
</script>

`closeAllPanels()` runs before every open so two panels can't render at once, and Escape returns focus to the button that opened it β€” never to the page's top, which would strand the user mid-task. MDN's aria-expanded reference covers which elements accept it.

How should keyboard navigation flow through a mega menu?

Keyboard users Tab through a disclosure-pattern mega menu in plain document order β€” the top-level link, its toggle button, then every link in the open panel β€” no roving tabindex or custom key handling required.

Arrow-key movement inside an open panel is a documented optional enhancement, not a requirement β€” a hand-rolled version that gets edge cases wrong is worse than the Tab order it improves on, the same "no ARIA is better than bad ARIA" logic behind most ecommerce ARIA fixes. Skip-link placement matters more here: the target must land past the entire nav, per 2.4.1 (Bypass Blocks), or a keyboard user re-tabs through dozens of links every load. Every focused link needs the indicator 2.4.7 (Focus Visible) requires and 2.2's 2.4.11 (Focus Not Obscured Minimum) protects.

<a class="skip-link" href="#main-content">Skip to content</a>

<style>
/* Fixes WCAG 2.4.7 (Focus Visible) and 2.4.11 (Focus Not Obscured, Minimum) β€”
   a visible ring on every trigger and link, and a z-index that never sits above it */
.submenu-toggle:focus-visible,
.mega-panel a:focus-visible {
  outline: 2px solid #1a5fff;
  outline-offset: 2px;
}
.mega-panel { z-index: 20; }
</style>

What about hover behavior?

WCAG 1.4.13 (Content on Hover or Focus) requires hover- or focus-triggered content be dismissible, hoverable, and persistent β€” a mega-panel must stay open as the pointer travels from trigger to panel, close on Escape, and never vanish just because the mouse paused mid-movement.

The fix: a generous close delay plus a listener on the panel itself, so crossing the gap doesn't register as "leaving."

// Fixes WCAG 1.4.13 β€” hoverable: pointer travels trigger-to-panel; dismissible: Escape
// (handled above) always closes it; persistent: never closes on a brief pause
let closeTimer;
document.querySelectorAll('.has-submenu').forEach((navItem) => {
  const btn = navItem.querySelector('.submenu-toggle');
  const panel = document.getElementById(btn.getAttribute('aria-controls'));
  const schedule = () => { closeTimer = setTimeout(closeAllPanels, 300); };
  navItem.addEventListener('mouseenter', () => { clearTimeout(closeTimer); openPanel(btn, panel); });
  navItem.addEventListener('mouseleave', schedule);
  panel.addEventListener('mouseenter', () => clearTimeout(closeTimer)); // cancels the pending close
  panel.addEventListener('mouseleave', schedule);
});

Wire hover and click through the same `openPanel`/`closeAllPanels` functions from the disclosure code, not a separate hover-only path β€” that keeps pointer and keyboard behavior in parity. For the Tab-order checks this depends on, see TestParty's keyboard navigation testing guide.

How do you make the mobile drawer version accessible?

A mobile mega-menu is a dialog: `role="dialog"`, a focus trap keeping Tab inside it, working Escape and close button, body scroll locked while open, and focus returned to the hamburger button on close.

<div class="nav-drawer" role="dialog" aria-modal="true" aria-labelledby="nav-drawer-title"
     id="nav-drawer" hidden>
  <h2 id="nav-drawer-title" class="visually-hidden">Site navigation</h2>
  <button type="button" aria-label="Close menu" onclick="closeDrawer()">Γ—</button>
  <ul><!-- top-level links; submenu toggles reuse the disclosure pattern from above --></ul>
</div>
<script>
const drawer = document.getElementById('nav-drawer');
let lastFocused;
function openDrawer(trigger) {
  lastFocused = trigger;
  drawer.hidden = false;
  document.body.style.overflow = 'hidden'; // locks background scroll
  drawer.querySelector('button').focus();
  drawer.addEventListener('keydown', trapFocus);
}
function closeDrawer() {
  drawer.hidden = true;
  document.body.style.overflow = '';
  drawer.removeEventListener('keydown', trapFocus);
  lastFocused.focus(); // fixes 2.4.3 β€” focus returns to the hamburger button
}
function trapFocus(e) {
  if (e.key === 'Escape') return closeDrawer(); // fixes 2.1.2 β€” Escape always exits
  if (e.key !== 'Tab') return;
  const focusable = drawer.querySelectorAll('button, [href], [tabindex]:not([tabindex="-1"])');
  const first = focusable[0], last = focusable[focusable.length - 1];
  if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
  else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
</script>

`overflow: hidden` on `<body>` isn't always enough on iOS Safari, which can still scroll behind a fixed drawer β€” pair it with `position: fixed` while open. VoiceOver also needs a collapsed submenu's `hidden` attribute to actually leave the accessibility tree, not just hide visually.

How does this apply to Shopify themes?

Dawn ships mega-menu markup that already leans on native disclosure semantics, but most premium themes β€” and customized Dawn installs β€” break it the moment someone restyles the dropdown.

Dawn's header wraps its dropdown in `<details>`/`<summary>` inside a `details-disclosure` element, keeping `aria-expanded` synced automatically β€” a rare case of a theme getting the disclosure pattern right by default. In TestParty's audits, the most common break is a custom build layered on top: a developer keeps the `<details>` markup but adds a `mouseenter` handler that opens the panel with `display: block` directly, bypassing the native toggle β€” so a screen reader still announces "collapsed" while the panel sits open, and the free Escape handling disappears with it (TestParty internal data).

{% comment %} Before: fails WCAG 4.1.2 β€” a custom build replaced Dawn's native
   <details>/<summary> with a styled div, so aria-expanded is never set {% endcomment %}
<li>
  <div class="mega-menu__toggle" onmouseenter="openPanel(this)">{{ link.title }}</div>
  <div class="mega-menu__content"><!-- sub-links --></div>
</li>

{% comment %} After: fixes 4.1.2 and 2.1.1 β€” restores Dawn's native disclosure
   element, so aria-expanded and keyboard support come free {% endcomment %}
<details-disclosure class="mega-menu">
  <details id="Details-{{ forloop.index }}">
    <summary class="mega-menu__toggle" aria-expanded="false">{{ link.title }}</summary>
    <div class="mega-menu__content" id="MegaMenu-Content-{{ forloop.index }}">
      <!-- sub-links loop through link.links here -->
    </div>
  </details>
</details-disclosure>

Shopify's Theme Store requirements cover only 16–22% of WCAG criteria, which is why Dawn still shows 30–100 violations overall despite its accessible nav defaults, and premium themes run 100–350 (TestParty theme audits, August 2026) β€” nav is rarely the theme's fault at purchase; it's usually whatever replaced its default markup afterward. See what a Shopify accessibility audit covers, source-code remediation, and WCAG 2.2 AA explained.

Frequently Asked Questions

Is a mega menu different from a small dropdown for accessibility purposes? Not structurally β€” the same disclosure pattern covers a two-item dropdown and a twelve-column mega-panel. More links just raise the stakes on heading structure and skip-link placement, since there's more for a keyboard user to tab past if either is missing.

Can I use `role="menu"` and `role="menuitem"` for a mega menu instead? No, unless you're building an actual application-style menu. Those roles pull links out of the normal Tab sequence and require you to build full roving-tabindex and arrow-key handling yourself β€” a mismatch for site navigation, where shoppers expect ordinary link and Tab behavior on every item.

Do I need arrow-key navigation inside an open mega-menu panel? No. It's a documented but optional enhancement in the APG's disclosure pattern, not a requirement β€” Tab already moves through every link in the panel in order, and a hand-rolled arrow-key handler that mishandles edge cases is worse than not adding one at all.

What's the single most common mega-menu bug TestParty finds in audits? A trigger β€” a `<div>` or customized `<summary>` β€” that opens visually but never sets `aria-expanded`, so a screen reader user hears no state change when the panel opens (TestParty internal data). This guide's disclosure code clears it in one pass.

Does Shopify's Dawn theme have an accessible mega menu by default? Largely, yes β€” Dawn's stock header uses native `<details>`/`<summary>` elements inside a `details-disclosure` custom element that keeps `aria-expanded` synced with the panel automatically. In TestParty's audits, it typically breaks only once a merchant or developer's custom nav build replaces that markup with something styled by hand.

How do I test a mega menu for accessibility before shipping a change? Run an automated scan, then a keyboard-only pass β€” Tab to every trigger, open a panel, confirm Escape closes it and focus returns β€” then a screen reader pass to confirm the state is announced. Automated tools catch a missing `aria-expanded` but can't judge whether Escape and focus return actually work.

Built with TestParty's cyborg approach β€” AI-powered research combined with human accessibility expertise. This article contains TestParty's editorial analysis based on publicly available information. We're an accessibility vendor with opinions informed by working with 100+ brands, and we encourage readers to do their own due diligence when evaluating any solution.

Stay informed

Accessibility insights delivered
straight to your inbox.

Contact Us

Automate the software work for accessibility compliance, end-to-end.

Empowering businesses with seamless digital accessibility solutionsβ€”simple, inclusive, effective.

Book a Demo