Building an Accessible Mega Menu in React
A mega menu is a navigation panel wide enough to hold several columns of links, opened from a top-level item in the header. Almost every accessible implementation gets the same two decisions right: it is a disclosure, not a role="menu" widget, and it opens on click, not on hover alone. Everything else — column layout, animation, hover convenience — is built on top of those two facts. This guide implements one in React, following the structural rules set out in Accessible Navigation & Landmark Structure.
WCAG Success Criteria Addressed:
2.1.1 Keyboard2.1.2 No Keyboard Trap1.4.13 Content on Hover or Focus4.1.2 Name, Role, Value
Prerequisites
- A React 18+ codebase (the examples use client components; in the App Router mark the file
'use client'). - A header that already renders navigation links, ideally inside a labelled
<nav>. - Familiarity with the disclosure pattern — a
<button aria-expanded>controlling a region — as described in the parent guide.
You do not need a headless UI library. The whole component is a button, a boolean, and a list of links; adding a menu library usually means adopting role="menu" semantics that a navigation panel should not have.
The Trigger: a Real Button With Real State
Each top-level item that opens a panel is a <button type="button">. Top-level items that navigate somewhere remain plain links. Mixing the two in one row is fine and common — what matters is that each element's role matches what it does.
Implementation Guidelines:
- Give the button
aria-expandedreflecting the open state andaria-controlspointing at the panel'sid. - Do not add
aria-haspopup="true". It announces "menu" and sets an expectation of arrow-key navigation you are deliberately not implementing. - Keep the button's accessible name identical to its visible label — the panel heading can repeat it, but the button should not gain extra hidden text.
- Never make a top-level item both a link and a panel trigger. One control, one action; a "click to navigate, hover to open" item is unusable by keyboard.
'use client';
import { useId, useRef, useState } from 'react';
export function MegaMenuItem({ label, children }: { label: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false);
const panelId = useId();
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<li>
<button
ref={buttonRef}
type="button"
// A11y rationale: 4.1.2 Name, Role, Value — the expanded state is the
// control's state, so it must live on the control, not the panel.
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((value) => !value)}
>
{label}
</button>
<div id={panelId} hidden={!open} className="mega-panel">
{children}
</div>
</li>
);
}
Using hidden rather than a CSS class to hide the panel matters: hidden removes the links from the tab order and the accessibility tree. A panel hidden with opacity: 0 or visibility: hidden on a parent that still occupies space leaves focusable links behind, and keyboard users tab into an invisible menu.
One Panel at a Time
With several triggers in the header, panel state belongs to the menu bar, not to each item. Hoisting it prevents two panels overlapping and makes "close the others" a single assignment.
Implementation Guidelines:
- Store the id of the open panel (or
null) in one piece of state at the navigation level. - Close the open panel when another trigger is activated, when focus leaves the navigation entirely, and on route change.
- Do not close on every
blur— moving focus from the trigger into its own panel fires blur, and closing there makes the panel unreachable by keyboard. - Use a
focusouthandler on the wrapper and checkevent.relatedTargetagainst the wrapper's contents.
'use client';
export function MegaMenu({ sections }: { sections: Section[] }) {
const [openId, setOpenId] = useState<string | null>(null);
const navRef = useRef<HTMLElement>(null);
return (
<nav
ref={navRef}
aria-label="Primary"
onFocusOut={(event) => {
// Only close when focus leaves the whole navigation, not when it moves
// from a trigger into the panel that trigger controls.
const next = event.relatedTarget as Node | null;
if (next && navRef.current?.contains(next)) return;
setOpenId(null);
}}
>
<ul>
{sections.map((section) => (
<MegaMenuItem
key={section.id}
section={section}
open={openId === section.id}
onToggle={() => setOpenId((id) => (id === section.id ? null : section.id))}
/>
))}
</ul>
</nav>
);
}
Escape, and Where Focus Lands
Escape must close the panel from anywhere inside it, and focus must return to the trigger. Without the return, a keyboard user who closes a panel is left with focus on a removed node, and the browser resets them to the top of the document.
Implementation Guidelines:
- Handle
Escapeon the wrapper element so it fires whether focus is on the trigger or on a link inside the panel. - Move focus back to the trigger with
buttonRef.current?.focus()after the state update, so the element still exists when you call it. - Do not trap focus. A navigation panel is not a dialog;
2.1.2 No Keyboard Trapexpects Tab to leave it naturally. - Close the panel when the user tabs past the last link, so the header does not stay visually open behind the next control.
function handleKeyDown(event: React.KeyboardEvent) {
if (event.key !== 'Escape' || !open) return;
event.stopPropagation();
onToggle();
// A11y rationale: 2.4.3 Focus Order — return focus to the control that opened
// the panel so the user keeps their place in the header.
buttonRef.current?.focus();
}
Hover Without Losing Keyboard Users
Hover opening is a genuine convenience for pointer users, and it is safe as an addition to click. The failure mode is a panel that only exists on hover: unreachable by keyboard, impossible on touch, and gone the instant the pointer strays.
Implementation Guidelines:
- Keep click as the primary interaction; treat hover as an accelerator that sets the same state.
- Add a short close delay (150–250 ms) so a diagonal mouse path between the trigger and the panel does not dismiss it — one half of
1.4.13 Content on Hover or Focus. - Make the panel itself hoverable: entering it must cancel the pending close.
- Never open on hover without also making the content dismissible with Escape, which
1.4.13requires for anything shown on hover.
const closeTimer = useRef<number>();
const openOnHover = () => {
window.clearTimeout(closeTimer.current);
onOpen();
};
const closeSoon = () => {
// 1.4.13 Content on Hover or Focus — hoverable content must survive a brief
// pointer excursion between the trigger and the panel.
closeTimer.current = window.setTimeout(onClose, 200);
};
On touch devices, treat the first tap as "open" rather than "navigate". If the trigger is a button, that happens automatically; if you have made it a link, the first tap will navigate away and the panel will never be seen — another reason the trigger must not be a link.
How to Verify
- Keyboard-only pass. Tab to each trigger, press
Enter, then Tab through the panel and out the other side. You should never be trapped, and Escape from any position should return you to the trigger with a visible focus ring. - Screen reader pass. With NVDA or VoiceOver, confirm each trigger is announced as "button, collapsed/expanded" and that the panel's links are announced as ordinary links — not as menu items.
- Automated scan. Run axe with a panel open; watch for
aria-valid-attr-value(a stalearia-controlsid) andbutton-name. See Automated Accessibility Testing with axe-core. - Hidden-link check. With every panel closed, Tab through the header and count the stops. If you land on links you cannot see, the panel is being hidden with CSS instead of
hidden. - Pointer-path check. Open a panel by hover and move the pointer diagonally into its far corner. If it closes on the way, the close delay is too short.
Common Accessibility Mistakes
- Using
role="menu"androle="menuitem". This promises arrow-key navigation and typeahead, removes the links from the links list, and makes the whole panel one tab stop. A navigation panel is a disclosure containing links. - Opening on hover only. The panel becomes unreachable by keyboard and unusable on touch. Hover is an enhancement layered on top of click, never the only path.
- Hiding the panel with
opacity: 0. The links stay focusable, so keyboard users tab into an invisible menu. Use thehiddenattribute ordisplay: none. - Closing on
blurfrom the trigger. Focus moving into the panel fires blur; closing there makes the panel impossible to reach by keyboard. Test focus movement against the whole wrapper instead. - Forgetting focus return on Escape. Focus lands on a removed node and the browser resets to the top of the page, costing the user every tab stop they had made.
- Duplicating the top-level item as a link inside the panel and as the trigger. If the section has a landing page, put one link to it as the first item in the panel — do not make the trigger navigate too.
Conclusion
An accessible mega menu is less work than an inaccessible one, because the accessible version is a disclosure: a button with aria-expanded, a panel hidden with hidden, one piece of state at the navigation level, Escape to close, focus back to the trigger. No roles, no roving tabindex, no focus trap. Hover and animation sit on top of that as pure enhancement, and every one of them degrades safely. The moment you reach for role="menu", you have signed up for a keyboard contract a navigation panel does not need — and users will notice the half-implemented version long before they notice the missing hover delay.
Frequently Asked Questions
Should the top-level trigger also be a link to a landing page? No. A control cannot both navigate and toggle without one behaviour being unreachable — on touch the first tap navigates, and keyboard users get no way to open the panel. If the section has a landing page, make the first item inside the panel a link to it, labelled "Overview" or the section name.
Do I need aria-haspopup on the trigger?
No, and it is actively unhelpful here. aria-haspopup="true" means "opens a menu" and leads screen reader users to expect arrow-key navigation. For a panel of links, aria-expanded plus aria-controls describes exactly what happens.
How should the panel be structured internally?
As headings and lists. Each column gets a heading (<h3> or <h4> depending on the page outline) followed by a <ul> of links. That makes the panel navigable by the headings rotor and keeps the tab order identical to the reading order.
What about animation on open and close?
Fine, provided the animation does not delay focusability and respects prefers-reduced-motion. Toggle hidden first and animate in; on close, animate out and set hidden at the end — or skip the exit animation entirely when reduced motion is requested, as described in Reduced Motion & Animation Accessibility.
Is a mega menu ever the wrong answer? Often. Four columns of links in a header is a symptom of a large information architecture, and for many sites a short header plus a well-structured landing page serves users better — including the keyboard user who now needs six presses to reach a link that could have been one page away.
Related guides
- Accessible Navigation & Landmark Structure — the parent guide, including when a nav is not a menu.
- Roving Tabindex for Navigation Menus — the single-tab-stop model, and when it is worth it.
- Building Accessible Dropdowns Without External UI Kits — the full menu contract, for cases that really need it.
- Keyboard Navigation Patterns for Modals — Escape handling and focus restoration in overlays.