// 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.
// 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
<!-- 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>
.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;
}
}
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);
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. |
// 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-controlsandaria-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 viamin-height: 2remand 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
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.
focusout listener with a requestAnimationFrame check to handle the brief gap between focus transitions.
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.
<h3> headings to each group so users can navigate by heading and understand the panel structure.