An Accessible Mega Menu in Next.js Without a Headless UI Library
It is a navigation landmark, not an application menu — and the ARIA menu pattern most tutorials copy removes your nav from every screen reader's link list. Six behaviours, eighty lines.
A mega menu is a navigation landmark containing lists of links — not an application menu. Build it from <nav>, <ul> and <button aria-expanded>, let Tab move through the links naturally, and close on Escape and outside click. The ARIA menu pattern that most tutorials copy is for application menus and actively makes link navigation worse. The same call comes up one component down, where this storefront's edition dropdown also declines role="menu" for a panel of plain links.
That distinction is the whole post. Get it right and a mega menu is about eighty lines of Next.js with one small Client Component; get it wrong and you ship a keyboard trap that passes an automated audit.
The pattern most tutorials get wrong
Search for an accessible mega menu and you will mostly find implementations built on the WAI-ARIA menu / menubar pattern: role="menubar", role="menu", role="menuitem", arrow-key navigation, and Tab moving out of the whole menu rather than through it.
That pattern exists for application menus — the File / Edit / View bar in a desktop-style app, where each item performs a command. Site navigation is a list of links to other pages, and the ARIA Authoring Practices Guide says so directly: it advises against the menu pattern for site navigation.
Applying it anyway causes real damage:
| Application menu pattern | Navigation pattern | |
|---|---|---|
| Role | menubar / menu / menuitem | nav + ul / li / a |
| Tab behaviour | Moves out of the entire menu | Moves through each link |
| Arrow keys | Required for navigation | Optional enhancement |
| Announced as | "menu, menu item" | "navigation, list, N items, link" |
| Screen-reader link list | Links do not appear | All links appear |
| Right-click "Open in new tab" | Often broken | Works |
| Correct for | Commands and actions | Site navigation |
The row that decides it in practice is the second-to-last. Screen reader users navigate unfamiliar sites by pulling up a list of links or landmarks. role="menuitem" removes your entire navigation from that list — the links are still on the page, and the tool most likely to be used to find them can no longer see them. Nothing in an automated audit will report this.
What a mega menu actually needs
Six behaviours. That is the complete specification:
- A trigger that is a real
<button>witharia-expandedreflecting state. - A panel of links marked up as lists, associated with the trigger via
aria-controls. - Tab moves through the links in DOM order.
- Escape closes the panel and returns focus to its trigger.
- Clicking or tabbing outside closes it.
- Opening a second panel closes the first.
Everything else — hover intent, arrow keys, animation — is enhancement. If you build the six and stop, you have an accessible mega menu.
The server/client split
Almost all of a mega menu is static markup, and in the App Router that should stay on the server. Only the open/closed state needs a client boundary, so the split is: a Server Component owns the nav and the link data, and a small Client Component owns one panel's disclosure state.
// components/organisms/SiteNav.tsx — Server Component, no "use client"
import { navSections } from "@/data/nav";
import { MegaMenuItem } from "@/components/molecules/MegaMenuItem";
export function SiteNav() {
return (
<nav aria-label="Main">
<ul className="flex items-center gap-1">
{navSections.map((section) =>
section.columns ? (
<li key={section.id}>
<MegaMenuItem id={section.id} label={section.label}>
<div className="grid grid-cols-3 gap-8 p-6">
{section.columns.map((column) => (
<div key={column.heading}>
<h2 className="mb-3 text-sm font-semibold text-gray-500">
{column.heading}
</h2>
<ul className="space-y-2">
{column.links.map((link) => (
<li key={link.href}>
<a href={link.href} className="block py-1">
{link.label}
<span className="block text-sm text-gray-500">
{link.description}
</span>
</a>
</li>
))}
</ul>
</div>
))}
</div>
</MegaMenuItem>
</li>
) : (
<li key={section.id}>
<a href={section.href}>{section.label}</a>
</li>
),
)}
</ul>
</nav>
);
}
The panel content is passed as children, so it renders on the server and arrives as HTML. The Client Component never sees your nav data — it only toggles visibility. That is the same discipline that keeps a marketing page's bundle small: "use client" on the leaf that needs state, never on the section that contains it.
Note the panel is inside the <li>, and the headings inside it are real <h2> elements. A mega menu column heading is a heading; making it a <div class="font-semibold"> is how you end up with a nav that a screen reader reads as one undifferentiated run of forty links.
The Client Component
"use client";
import { useEffect, useId, useRef, useState } from "react";
export function MegaMenuItem({
label,
children,
}: {
id: string;
label: string;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const panelId = useId();
const wrapperRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
}
// Covers click-outside AND tab-outside: focusout fires for keyboard
// traversal, pointerdown for mouse. Using only one of them leaves a
// panel open in the other input mode.
function onFocusOut(event: FocusEvent) {
const next = event.relatedTarget as Node | null;
if (next && wrapperRef.current?.contains(next)) return;
setOpen(false);
}
function onPointerDown(event: PointerEvent) {
if (wrapperRef.current?.contains(event.target as Node)) return;
setOpen(false);
}
document.addEventListener("keydown", onKeyDown);
document.addEventListener("pointerdown", onPointerDown);
wrapperRef.current?.addEventListener("focusout", onFocusOut);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.removeEventListener("pointerdown", onPointerDown);
wrapperRef.current?.removeEventListener("focusout", onFocusOut);
};
}, [open]);
return (
<div ref={wrapperRef} className="relative">
<button
ref={triggerRef}
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((value) => !value)}
className="flex items-center gap-1 px-3 py-2"
>
{label}
<ChevronIcon aria-hidden="true" className={open ? "rotate-180" : ""} />
</button>
{/* Rendered always, hidden with `hidden` — see below. */}
<div
id={panelId}
hidden={!open}
className="absolute top-full left-0 z-40 w-[48rem] rounded-2xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
>
{children}
</div>
</div>
);
}
Four decisions worth defending:
aria-expandedon the button, and nothing else. Noaria-haspopup="menu"— that announces an application menu and sets the wrong expectation. A disclosure needsaria-expandedandaria-controls; that is the entire contract.hidden, not conditional rendering.{open && <Panel/>}means the links do not exist in the initial HTML. Crawlers see an empty nav, and your internal linking — the thing a mega menu exists for — silently stops counting.hiddenkeeps the markup in the document and out of the accessibility tree.focusoutandpointerdowntogether. A mouse user clicks away; a keyboard user tabs away. Handling onlypointerdownleaves the panel open behind a keyboard user who has already tabbed into the page content. This is the single most common bug in hand-rolled menus.aria-hiddenon the chevron. It is decoration. Without it, the accessible name becomes "Products, image".
Do not use display: none for an animation
If you want the panel to fade or slide, hidden and a CSS transition fight each other — the element is removed from layout before the transition runs. Use hidden="until-found" where you want find-in-page to still reach the content, or drive visibility with opacity plus visibility: hidden and let visibility be the transitioned property. Keep the panel out of the tab order while it is closed either way; a panel that is visually hidden but still focusable is worse than no panel, because Tab appears to vanish into nothing.
If you animate anything here, honour prefers-reduced-motion — the same guard the scroll-driven animation post applies to everything it introduces.
Hover, and why it must not be the only way in
On a pointer device, hover-to-open is expected. Two rules keep it from breaking everything else:
- Hover opens, but click also opens. Touch devices have no hover; a hover-only trigger produces the classic "first tap opens, second tap navigates, sometimes" behaviour, and on some Android browsers the first tap navigates to the section page immediately.
- Add a close delay, not an open delay. Users move the pointer diagonally toward a link and clip the corner of the panel. Around 150–250 ms of grace on close removes almost all of that frustration; a delay on open makes the menu feel broken.
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
function scheduleClose() {
closeTimer.current = setTimeout(() => setOpen(false), 200);
}
function cancelClose() {
if (closeTimer.current) clearTimeout(closeTimer.current);
}
Attach onPointerEnter={() => { cancelClose(); setOpen(true); }} and onPointerLeave={scheduleClose} to the wrapper — and gate them behind a (hover: hover) media query check so a touch device never receives them.
On mobile, it is a different component
A three-column panel does not become mobile navigation by getting narrower. Below the breakpoint, render an accordion inside the mobile drawer: the same link data, one list per section, one disclosure each. Same aria-expanded contract, no positioning, no hover.
The rule is that the panel content is data, not markup — which is why navSections above is a typed array rather than JSX. One data source, two presentations, no risk of the two navs drifting apart.
Mistakes and how they show up
| Mistake | What happens | Fix |
|---|---|---|
role="menubar" / menuitem | Links vanish from the screen reader's link list | <nav> + <ul> + <a> |
aria-haspopup="menu" on the trigger | Announced as an application menu | Just aria-expanded + aria-controls |
{open && <Panel/>} | Nav links absent from server HTML | Render always, toggle hidden |
Only pointerdown for outside-close | Panel stays open behind keyboard users | Also handle focusout |
<div> trigger with an onClick | No Enter/Space, no role, no focus | A real <button type="button"> |
| No Escape handler | Keyboard users cannot dismiss | Escape closes and restores focus |
| Focus not returned to the trigger | Focus lands on <body>, place lost | triggerRef.current?.focus() |
Column headings as styled <div>s | Nav reads as one flat run of links | Real <h2> elements |
| Hover-only opening | Broken on touch, unusable by keyboard | Click opens too; hover is enhancement |
| Delay on open | Menu feels laggy or broken | Delay on close only |
"use client" on the whole nav | Link data ships to the browser | Client boundary on the disclosure leaf |
Frequently asked questions
Should arrow keys work in a mega menu? They may, as an enhancement, but they must not replace Tab. The navigation pattern's contract is that Tab moves through links; adding arrow-key movement on top is fine and some users appreciate it. What breaks things is implementing arrow keys instead of Tab — that is the application-menu pattern, and it means a keyboard user tabbing through your page skips your entire navigation.
Do I need a focus trap inside the panel? No, and adding one is a bug. Focus traps are for modal dialogs, where the rest of the page is inert. A mega menu is not modal — the user must be able to Tab straight out of it into the page. Trap focus and you have built a keyboard trap, which is a WCAG 2.1.2 failure.
Will hidden panel content hurt SEO?
No. Content hidden by CSS or the hidden attribute is still crawled and indexed; Google has been explicit that it does not penalise navigation hidden behind a disclosure, because that is normal site behaviour. What does hurt is content that is not in the HTML at all — which is exactly what conditional rendering produces, and the reason for the hidden choice above.
Is it worth using Radix or Headless UI instead? It is a legitimate choice, and Radix's Navigation Menu implements the navigation pattern correctly rather than the menu pattern. Weigh it against the bundle: a mega menu is one of the few widgets where hand-rolling is genuinely tractable — six behaviours, roughly eighty lines — and a marketing page is where client JavaScript costs the most. On an app that already ships a headless library, use it. On a landing page that ships nothing else, do not add one for this.
How many links belong in a mega menu? Fewer than the format tempts you to add. Group them under headings, keep each column to a scannable length, and if a section needs more than three columns it probably wants a real landing page that the menu links to. A menu with sixty links is a sitemap that opens on hover.
Templates with the navigation already built
Navigation is the component most likely to be rewritten three times in a marketing build, and it is finished in a template.
ASoc Nexus is a smart-apps SaaS site with a solutions grid and an app-download section — a multi-destination nav of exactly the kind this pattern is for. ASoc Synth is an AI-workspace SaaS site whose feature toolkit, use cases and workflow sections are the natural columns of a mega menu. ASoc Realm markets property-management software across features, use-case sectors and a journal — three top-level groups, each with real depth behind it.
Browse the full set of Next.js landing page templates, or the Tailwind landing page templates. For the rest of the accessibility and performance work a marketing page needs, the SaaS landing page guide covers what our own Lighthouse audit caught.
