// pattern

Accessible Mega Menu Pattern

A horizontal navigation bar where top-level items open large, multi-column dropdown panels containing grouped links. This pattern uses the disclosure pattern (not role="menu") because the panels contain standard navigation links, not action menu items.

aria-required wcag-2.2-aa intermediate

// 01 · live demo

Live Demo

Press Enter or Space to open a panel. Use Tab to move through links. Press Escape to close.

// 02 · the code

The Code

HTML
<!-- Mega menu navigation -->
<nav class="mega-menu" aria-label="Main navigation">
  <ul class="mega-menu__bar">

    <!-- Each top-level item is a disclosure:
         button + hidden panel. No role="menu" needed
         because the panel contains navigation links,
         not action items. -->
    <li class="mega-menu__item">
      <button type="button"
              aria-expanded="false"
              aria-haspopup="true"
              aria-controls="panel-products"
              id="trigger-products">
        Products
      </button>

      <!-- Panel: multi-column layout with grouped links.
           Headings and lists provide structure for
           screen reader navigation. -->
      <div class="mega-menu__panel"
           id="panel-products"
           hidden>
        <div class="mega-menu__columns">
          <div class="mega-menu__group">
            <h3>Software</h3>
            <ul>
              <li><a href="/products/analytics">
                Analytics Platform
              </a></li>
              <li><a href="/products/design">
                Design Tools
              </a></li>
              <li><a href="/products/api">
                Developer API
              </a></li>
            </ul>
          </div>
          <div class="mega-menu__group">
            <h3>Hardware</h3>
            <ul>
              <li><a href="/products/keyboards">
                Keyboards
              </a></li>
              <li><a href="/products/displays">
                Displays
              </a></li>
            </ul>
          </div>
        </div>
      </div>
    </li>

    <!-- Repeat for each top-level item -->
    <li class="mega-menu__item">
      <button type="button"
              aria-expanded="false"
              aria-haspopup="true"
              aria-controls="panel-resources"
              id="trigger-resources">
        Resources
      </button>
      <div class="mega-menu__panel"
           id="panel-resources"
           hidden>
        <!-- columns and groups ... -->
      </div>
    </li>

  </ul>
</nav>
CSS
.mega-menu__bar {
  display: flex;
  align-items: center;
  list-style: none;
  margin: 0;
  padding: 0;
}

.mega-menu__item {
  position: relative;
}

.mega-menu__item button {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.75rem 1rem;
  border: none;
  background: transparent;
  font-size: 0.875rem;
  font-weight: 600;
  cursor: pointer;
  /* SC 2.5.8: minimum 24x24 target */
  min-height: 2.75rem;
}

.mega-menu__item button:hover {
  background: #f3f4f6;
}

/* SC 2.4.13: visible focus indicator */
.mega-menu__item button:focus-visible {
  outline: 2px solid #5b2a86;
  outline-offset: -2px;
}

.mega-menu__item button[aria-expanded="true"] {
  background: #f3f4f6;
}

.mega-menu__panel {
  position: absolute;
  top: 100%;
  left: 0;
  min-width: 500px;
  padding: 1.5rem;
  background: #fff;
  border: 1px solid #d1d5db;
  border-radius: 0 0 0.375rem 0.375rem;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
  z-index: 100;
}

.mega-menu__panel[hidden] {
  display: none;
}

.mega-menu__columns {
  display: grid;
  grid-template-columns: repeat(
    auto-fit, minmax(160px, 1fr)
  );
  gap: 1.5rem;
}

.mega-menu__group h3 {
  font-size: 0.75rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin-bottom: 0.5rem;
}

.mega-menu__group ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.mega-menu__group a {
  display: flex;
  align-items: center;
  padding: 0.375rem 0.5rem;
  font-size: 0.875rem;
  text-decoration: none;
  border-radius: 0.25rem;
  /* SC 2.5.8: minimum target size */
  min-height: 2rem;
}

.mega-menu__group a:hover {
  background: #f3f4f6;
}

.mega-menu__group a:focus-visible {
  outline: 2px solid #5b2a86;
  outline-offset: -2px;
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .mega-menu__item button,
  .mega-menu__group a {
    transition: none;
  }
}
JavaScript
function initMegaMenu(nav) {
  const items = nav.querySelectorAll('.mega-menu__item');

  items.forEach(item => {
    const trigger = item.querySelector('button');
    const panel = item.querySelector('.mega-menu__panel');
    if (!trigger || !panel) return;

    function openPanel() {
      // Close any other open panels first
      closeAllPanels();
      panel.hidden = false;
      trigger.setAttribute('aria-expanded', 'true');
    }

    function closePanel(returnFocus = true) {
      panel.hidden = true;
      trigger.setAttribute('aria-expanded', 'false');
      if (returnFocus) {
        trigger.focus();
      }
    }

    // Toggle panel on click
    trigger.addEventListener('click', () => {
      const isOpen = trigger.getAttribute(
        'aria-expanded'
      ) === 'true';
      if (isOpen) {
        closePanel();
      } else {
        openPanel();
      }
    });

    // Keyboard: Escape closes the panel
    item.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') {
        const isOpen = trigger.getAttribute(
          'aria-expanded'
        ) === 'true';
        if (isOpen) {
          closePanel();
        }
      }
    });

    // Close when focus leaves the menu item
    item.addEventListener('focusout', (e) => {
      // Wait a tick for the new activeElement
      requestAnimationFrame(() => {
        if (!item.contains(document.activeElement)) {
          closePanel(false);
        }
      });
    });
  });

  function closeAllPanels() {
    items.forEach(item => {
      const trigger = item.querySelector('button');
      const panel =
        item.querySelector('.mega-menu__panel');
      if (trigger && panel) {
        panel.hidden = true;
        trigger.setAttribute(
          'aria-expanded', 'false'
        );
      }
    });
  }

  // Close on outside click
  document.addEventListener('click', (e) => {
    if (!nav.contains(e.target)) {
      closeAllPanels();
    }
  });
}

// Initialize
document.querySelectorAll('.mega-menu')
  .forEach(initMegaMenu);
Why not move focus into the panel on open? Unlike a dropdown menu (which uses role="menu" and arrow key navigation), a mega menu panel contains standard navigation links. Users expect to press Tab to move into the panel, just like any other group of links. Moving focus automatically would be disorienting because the panel is not a composite widget.

// 03 · why these decisions

Why These Decisions

Why buttons, not links, for triggers

The top-level items that open panels are not navigating to a new page -- they are toggling the visibility of a panel. This is an action, and actions belong on <button> elements. A <button> is keyboard-activatable by default and communicates "this does something on this page" rather than "this takes you somewhere." If a top-level item also needs to link to a page, place the link inside the panel as the first item.

Why aria-expanded on each trigger

This attribute communicates whether the panel is currently open or closed. Screen readers announce "expanded" or "collapsed" alongside the button name, so users know the current state without seeing the visual dropdown. Toggling this attribute every time the panel opens or closes keeps the announced state in sync with what sighted users see.

Why not role="menu" (disclosure pattern vs. menu pattern)

The role="menu" pattern is for action menus: lists of commands like "Copy", "Paste", "Delete." It requires arrow key navigation and role="menuitem" on each item. A mega menu panel contains navigation links grouped into categories -- these are not actions. Using the disclosure pattern (button + hidden panel) keeps the links in the normal tab order, which is what users expect from navigation. The APG explicitly recommends the disclosure pattern for site navigation menus.

Why close when focus leaves

If a panel stays open after the user tabs past the last link, it obscures page content and creates confusion. Closing the panel when focus moves outside the menu item ensures the panel is only visible when the user is actively interacting with it. The focusout event combined with a requestAnimationFrame check handles this reliably, accounting for the brief moment when focus is between elements.

Why headings inside panels

The column headings inside each panel give screen reader users a way to navigate the panel structure. A user can press H in browse mode to jump between groups, or use the rotor/elements list to see all headings. Without headings, the panel is a flat list of links with no indication of how they are organized.

// 04 · keyboard interaction

Keyboard Interaction

Key Action
Enter / Space Opens or closes the panel when focus is on a trigger button
Escape Closes the open panel and returns focus to its trigger button
Tab Moves focus to the next focusable element: into the panel links, then to the next trigger, then out of the menu. Closes the panel when focus leaves the menu item.
Shift + Tab Moves focus to the previous focusable element. Closes the panel when focus leaves the menu item.
Arrow Left / Arrow Right (optional) Can be added to move between top-level trigger buttons. Not required by the disclosure pattern but can improve efficiency for frequent users.
Tab navigation, not arrow keys Because the mega menu uses the disclosure pattern (not the menu pattern), users navigate panel links with Tab rather than arrow keys. This matches the behavior of standard navigation links and avoids surprising users who expect Tab to move through links.

// 05 · wcag 2.2 success criteria

WCAG 2.2 Success Criteria

This pattern satisfies the following WCAG 2.2 success criteria:

  • 1.3.1 Info and Relationships Level A — Panel structure is conveyed through headings and lists. The trigger-to-panel relationship is established via aria-controls and aria-expanded.
  • 2.1.1 Keyboard Level A — Trigger buttons are activatable via Enter/Space. All panel links are reachable via Tab. Escape closes panels.
  • 2.4.3 Focus Order Level A — Focus moves logically: trigger button, then into panel links (when open), then to the next trigger. Escape returns focus to the trigger.
  • 2.5.8 Target Size (Minimum) Level AA — Trigger buttons meet 24x24px minimum via min-height: 2.75rem. Panel links meet the minimum via min-height: 2rem and sufficient padding.
  • 4.1.2 Name, Role, Value Level A — Trigger buttons expose their name (button text), popup hint (aria-haspopup), and expanded state (aria-expanded). Panel links are standard <a> elements with accessible names.

// 06 · screen reader behavior

Screen Reader Behavior

When navigating to a trigger

  • NVDA: "Products, button, collapsed, has popup" -- announces button name, role, state, and popup hint
  • JAWS: "Products button, menu, collapsed" -- announces name, role, popup hint, and state
  • VoiceOver: "Products, collapsed, pop-up button" -- announces name, state, and popup role

When opening a panel

  • NVDA: "Products, button, expanded, has popup" -- announces the state change to expanded
  • JAWS: "Products button, menu, expanded" -- confirms expansion
  • VoiceOver: "Products, expanded, pop-up button" -- confirms expansion

When tabbing into the panel

  • NVDA: "Software, heading level 3" then "Analytics Platform, link" -- announces the group heading, then the first link
  • JAWS: "heading level 3, Software" then "Analytics Platform, link" -- similar announcement
  • VoiceOver: "Software, heading level 3" then "Analytics Platform, link" -- announces heading then link

Navigating by headings

Screen reader users can press H in browse mode to jump between group headings inside the panel. This lets them skip entire groups to find the category they want, rather than tabbing through every link. The heading structure is essential for efficient navigation of large panels.

// 07 · common mistakes

Common Mistakes

Using role="menu" and role="menuitem" for navigation links The menu pattern is for action menus (commands), not navigation menus (links to pages). Using role="menu" forces arrow key navigation and breaks the expected Tab behavior. Screen readers enter "menu interaction mode" where Tab may not work as expected. Use the disclosure pattern instead: a button that toggles a panel of standard links.
Opening panels on hover only A hover-only mega menu is inaccessible to keyboard users, touch screen users, and many assistive technology users. The panel must be toggled via keyboard-activatable buttons. Hover can be added as an enhancement, but Enter/Space activation must always be the primary interaction.
Not closing the panel when focus leaves If the panel stays open after the user tabs past all its links, it obscures page content and creates a confusing visual state. Always close the panel when focus moves outside the menu item. Use a focusout listener with a requestAnimationFrame check to handle the brief gap between focus transitions.
Missing aria-expanded on triggers Without aria-expanded, screen reader users cannot determine whether a panel is open or closed. They must try to tab into the panel to discover its state. Always toggle aria-expanded between "true" and "false" when the panel opens and closes.
Not closing on Escape If pressing Escape does not close the panel, keyboard users have no reliable way to dismiss it without tabbing through all links. Escape must close the panel and return focus to the trigger button.
No headings inside panels Without headings to label each column group, screen reader users perceive the panel as a flat, undifferentiated list of links. Add <h3> headings to each group so users can navigate by heading and understand the panel structure.