Skip to main content
ASoc
Tutorial

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.

The ASoc Team11 min read

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 patternNavigation pattern
Rolemenubar / menu / menuitemnav + ul / li / a
Tab behaviourMoves out of the entire menuMoves through each link
Arrow keysRequired for navigationOptional enhancement
Announced as"menu, menu item""navigation, list, N items, link"
Screen-reader link listLinks do not appearAll links appear
Right-click "Open in new tab"Often brokenWorks
Correct forCommands and actionsSite 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:

  1. A trigger that is a real <button> with aria-expanded reflecting state.
  2. A panel of links marked up as lists, associated with the trigger via aria-controls.
  3. Tab moves through the links in DOM order.
  4. Escape closes the panel and returns focus to its trigger.
  5. Clicking or tabbing outside closes it.
  6. 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-expanded on the button, and nothing else. No aria-haspopup="menu" — that announces an application menu and sets the wrong expectation. A disclosure needs aria-expanded and aria-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. hidden keeps the markup in the document and out of the accessibility tree.
  • focusout and pointerdown together. A mouse user clicks away; a keyboard user tabs away. Handling only pointerdown leaves 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-hidden on 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

MistakeWhat happensFix
role="menubar" / menuitemLinks vanish from the screen reader's link list<nav> + <ul> + <a>
aria-haspopup="menu" on the triggerAnnounced as an application menuJust aria-expanded + aria-controls
{open && <Panel/>}Nav links absent from server HTMLRender always, toggle hidden
Only pointerdown for outside-closePanel stays open behind keyboard usersAlso handle focusout
<div> trigger with an onClickNo Enter/Space, no role, no focusA real <button type="button">
No Escape handlerKeyboard users cannot dismissEscape closes and restores focus
Focus not returned to the triggerFocus lands on <body>, place losttriggerRef.current?.focus()
Column headings as styled <div>sNav reads as one flat run of linksReal <h2> elements
Hover-only openingBroken on touch, unusable by keyboardClick opens too; hover is enhancement
Delay on openMenu feels laggy or brokenDelay on close only
"use client" on the whole navLink data ships to the browserClient 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.

Keep reading

Tutorial12 min read

How to Build an Admin Dashboard with Next.js 16 and Tailwind CSS v4

A working admin dashboard in Next.js 16 and Tailwind CSS v4 — App Router layouts, a CSS-first theme, an accessible sidebar, and the server/client split that keeps it fast.

Read more
Tutorial10 min read

Next.js on AWS: Amplify, Lambda, ECS or EC2 — 8 Routes Decide It

Amplify, Lambda via SST, ECS Fargate, or EC2 — AWS has no framework-aware default. This build's own 428 static and 8 Node-runtime dynamic routes decide which compute layer actually fits.

Read more
Tutorial10 min read

The Next.js Bundle Analyzer Doesn't Run on Turbopack. Here's What Does.

ANALYZE=true writes nothing in Next 16, and the route table no longer prints First Load JS. The replacement, where it hides its output, and the 42 KB one constant was costing twelve routes.

Read more