core accessibility principles for modern frameworks

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 Keyboard
  • 2.1.2 No Keyboard Trap
  • 1.4.13 Content on Hover or Focus
  • 4.1.2 Name, Role, Value
Structure and state of a mega menu built as a disclosure A header bar holds three top-level buttons. The second button is expanded, shown by aria-expanded true, and controls a wide panel below it containing three columns of links, each column introduced by a heading. Annotations note that each top-level control is a real button, that only one panel is open at a time, that the panel is a plain list of links with no menu roles, and that Escape closes the panel and returns focus to its button. Products Guides Support 3 buttons, 3 tab stops aria-expanded="true" · aria-controls="panel-guides" Core principles Semantics & ARIA Focus management Keyboard patterns Colour & theming Frameworks React patterns Next.js App Router Server Components Hooks Testing axe-core jest-axe Playwright CI gating Inside the panel: headings plus plain links. No role="menu", no roving tabindex — Tab walks the links in order. Escape closes the panel from anywhere inside it and returns focus to the Guides button.

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-expanded reflecting the open state and aria-controls pointing at the panel's id.
  • 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 focusout handler on the wrapper and check event.relatedTarget against 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.

Keyboard paths through an open mega menu A state diagram with four states. From the trigger button, Enter or Space opens the panel and focus stays on the trigger. Tab from there moves into the first link in the panel. Continuing to Tab walks the links; Tab past the last link closes the panel and moves on to the next header control. From any point inside, Escape closes the panel and returns focus to the trigger. A note states that focus is never trapped inside the panel. Trigger focused panel closed Panel open focus still on trigger Inside the panel Tab walks the links Enter Tab Escape — closes the panel and returns focus to the trigger Tab past the last link closes the panel and continues to the next header control — focus is never trapped.

Implementation Guidelines:

  • Handle Escape on 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 Trap expects 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.13 requires 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);
};
Why a hover-close delay is required Two pointer paths from a trigger to a link inside the panel below it. On the top path, with no delay, the pointer briefly crosses the gap between the trigger and the panel, the panel closes instantly and the link is never reached. On the bottom path, a two hundred millisecond close timer is started when the pointer leaves the trigger and cancelled when it enters the panel, so the same diagonal movement succeeds. No close delay Trigger Panel already closed — the link is unreachable pointer crosses the gap → mouseleave fires 200 ms close timer, cancelled on panel enter Trigger Panel still open — the pointer arrives in time timer cancelled

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 stale aria-controls id) and button-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" and role="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 the hidden attribute or display: none.
  • Closing on blur from 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.