DEV Community

Priya Nair
Priya Nair

Posted on

Designing React navigation menus that meet EN 301 549’s 2024 focus‑visible and logical order requirements for complex mega‑menus

Building Accessible React Mega‑Menus for EN 301 549 2024 Focus‑Visible and Logical Order

Meta: Learn how to build React mega‑menus that satisfy EN 301 549 focus‑visible and logical order rules, with WCAG‑aligned code examples.


Introduction

Mega‑menus are a staple of modern e‑commerce and SaaS interfaces. They pack dozens of links into a single dropdown, making navigation fast for sighted mouse users. Yet the same richness creates real barriers for keyboard‑only and screen‑reader users: focus can jump unpredictably, visible focus indicators disappear, and the logical reading order can become a maze.

EN 301 549, the European accessibility standard that aligns with WCAG 2.1 AA, released a 2024 update that sharpens two requirements for complex components:

  • Focus‑visible (WCAG 2.4.7) – a visible indicator must be present when an element receives keyboard focus.
  • Logical order (WCAG 2.4.3) – the sequence in which focus moves must follow the visual and DOM order.

When you build a mega‑menu in React, meeting these criteria isn’t just about ticking a compliance box; it’s about ensuring that every user, regardless of input method, can predictably move through the menu and reach their destination. In this article I’ll walk through a practical, code‑first approach to crafting a React mega‑menu that satisfies EN 301 549’s focus‑visible and logical order rules, while also touching on related WCAG success criteria. I’ll show you how to test the result, and how Tessera’s Deep Scan can give you confidence that your implementation holds up under automated scrutiny.


Understanding EN 301 549 Focus‑Visible and Logical Order

EN 301 549 references WCAG 2.1 AA directly, so any WCAG 2.1 success criterion that applies to focus also applies under the European standard. The 2024 amendment adds explicit wording:

“For user interface components that contain multiple focusable items, the visible focus indicator shall be perceivable and the focus navigation order shall be logical and predictable.”

In practice this means:

  1. Focus‑visible – you must not rely solely on :focus-visible being hidden by a custom outline removal. The indicator needs sufficient contrast (≥ 3:1 against adjacent colors) and must not be removed via outline:none without a replacement.
  2. Logical order – the DOM order of focusable items must match the visual layout. If you use CSS order, flex-direction: row-reverse, or absolute positioning to reorder items visually, you must also adjust the DOM or use tabindex carefully to avoid a mismatch.

Both criteria map to WCAG 2.4.3 (Focus Order) and WCAG 2.4.7 (Focus Visible). Meeting them also satisfies EN 301 549’s clause 4.2.4.2 (Focus visible) and 4.2.4.3 (Focus order).


Why Mega‑Menus Are Tricky

A typical mega‑menu structure looks like this:

<nav aria-label="Main navigation">
  <button id="mega-menu-trigger" aria-haspopup="true" aria-expanded="false">
    Menu
  </button>
  <div id="mega-menu-panel" role="menu" hidden>
    <div role="none">
      <h2 role="none">Shop</h2>
      <ul role="none">
        <li><a href="/shop/shoes">Shoes</a></li>
        <li><a href="/shop/clothing">Clothing</a></li>
        <!-- … -->
      </ul>
    </div>
    <!-- more columns … -->
  </div>
</nav>
Enter fullscreen mode Exit fullscreen mode

Challenges include:

  • Focus traps – when the panel opens, focus must move inside it and not escape until the user closes it.
  • Visible focus – custom dropdown animations often remove the outline; you need to replace it with a visible indicator that works in both light and dark modes.
  • Logical order – columns may be displayed side‑by‑side with CSS flex, but the DOM may list them top‑to‑bottom. Screen‑reader users will hear items in DOM order, which can feel disorganized if the visual layout groups by category.
  • Dynamic content – mega‑menus often load content via AJAX; you must maintain focus management after each update.

Addressing these points requires a combination of ARIA roles, focus‑management JavaScript, and thoughtful CSS.


Core Accessibility Foundations

Before diving into code, let’s map the relevant success criteria:

WCAG Description Relevance to Mega‑Menu
2.1.1 Keyboard All menu items must be operable via keyboard.
2.1.2 No Keyboard Trap Users must be able to exit the menu.
2.4.3 Focus Order Navigation order must be logical and predictable.
2.4.7 Focus Visible A visible focus indicator must be present.
1.3.1 Info & 1.3.1 Info and Relationships Proper use of ARIA roles/labels conveys structure.
4.1.2 Name, Role, Value Interactive controls must have accessible names.

Meeting these criteria gives you a solid baseline for EN 301 549 compliance.


Building a Keyboard‑Navigable Mega‑Menu in React

Below is a complete, copy‑pasteable example that implements a mega‑menu with proper focus trapping, visible focus, and logical order. I’ve added comments to explain each piece.

import React, { useRef, useState, useEffect } from "react";
import "./MegaMenu.css"; // contains focus-visible styles

export default function MegaMenu({ columns }) {
  const triggerRef = useRef(null);
  const panelRef = useRef(null);
  const [isOpen, setIsOpen] = useState(false);

  // Focusable elements inside the panel (excluding the trigger)
  const getFocusableElements = () => {
    const panel = panelRef.current;
    if (!panel) return [];
    const selectors = [
      'a[href]',
      'area[href]',
      'input:not([disabled]):not([type="hidden"])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      'button:not([disabled])',
      'iframe',
      'object',
      'embed',
      '[tabindex]:not([tabindex="-1"])',
      '[contenteditable]',
    ];
    return Array.from(panel.querySelectorAll(selectors.join(",")));
  };

  // Trap focus inside the panel when open
  useEffect(() => {
    if (!isOpen) return;

    const panel = panelRef.current;
    const focusable = getFocusableElements();
    const firstEl = focusable[0];
    const lastEl = focusable[focusable.length - 1];

    // Move focus to first focusable element
    if (firstEl) firstEl.focus();

    const handleKeyDown = (e) => {
      if (e.key === "Tab") {
        if (e.shiftKey) {
          // Shift + Tab
          if (document.activeElement === firstEl) {
            e.preventDefault();
            lastEl.focus();
          }
        } else {
          // Tab
          if (document.activeElement === lastEl) {
            e.preventDefault();
            firstEl.focus();
          }
        }
      }

      // Escape closes the menu and returns focus to trigger
      if (e.key === "Escape") {
        setIsOpen(false);
        triggerRef.current.focus();
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [isOpen]);

  // Close when clicking outside
  useEffect(() => {
    if (!isOpen) return;
    const handleClickOutside = (e) => {
      if (
        panelRef.current &&
        !panelRef.current.contains(e.target) &&
        !triggerRef.current.contains(e.target)
      ) {
        setIsOpen(false);
        triggerRef.current.focus();
      }
    };
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, [isOpen]);

  return (
    <>
      <button
        ref={triggerRef}
        id="mega-menu-trigger"
        aria-haspopup="true"
        aria-expanded={isOpen}
        aria-controls="mega-menu-panel"
        className="menu-trigger"
      >
        Menu
      </button>

      <div
        ref={panelRef}
        id="mega-menu-panel"
        role="menu"
        hidden={!isOpen}
        className={`mega-menu-panel ${isOpen ? "open" : ""}`}
      >
        {columns.map((col, idx) => (
          <section key={idx} role="none" className="menu-column">
            {col.title && (
              <h2 role="none" className="menu-column-title">
                {col.title}
              </h2>
            )}
            <ul role="none" className="menu-list">
              {col.links.map((link, liIdx) => (
                <li key={liIdx}>
                  <a
                    href={link.url}
                    className="menu-link"
                    // Ensure the link gets visible focus via CSS
                  >
                    {link.label}
                  </a>
                </li>
              ))}
            </ul>
          </section>
        ))}
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

CSS for Focus‑Visible

Create MegaMenu.css (or add to your global stylesheet) with a high‑contrast outline that respects the user’s preference for :focus-visible:

/* Base styles – keep default outline but enhance it */
.menu-trigger,
.menu-link {
  outline: 2px solid transparent;
  outline-offset: 2px;
}

/* When the element receives keyboard focus, show a solid indicator */
.menu-trigger:focus-visible,
.menu-link:focus-visible {
  outline-color: #0066ff; /* brand blue, meets 3:1 contrast on white/gray */
}

/* Optional: dark mode support */
@media (prefers-color-scheme: dark) {
  .menu-trigger:focus-visible,
  .menu-link:focus-visible {
    outline-color: #66b2ff; /* lighter blue for dark background */
  }
}

/* Ensure the panel is hidden until opened */
.mega-menu-panel[hidden] {
  display: none;
}
.mega-menu-panel.open {
  display: block;
  /* Add any entrance animation you like – just don’t remove outline */
  animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}
Enter fullscreen mode Exit fullscreen mode

Why this works

  • The :focus-visible pseudo‑class ensures the outline appears only for keyboard users, preserving the clean look for mouse users.
  • The outline color is chosen to meet the 3:1 contrast rule required by WCAG 2.4.7.
  • Focus trapping is handled in the useEffect that runs when isOpen toggles. It moves focus to the first link, loops shift‑tab/tab at the boundaries, and returns focus to the trigger on Escape.
  • The click‑outside listener closes the menu and restores focus, satisfying 2.1.2 (no keyboard trap).

Managing Focus Traps and Return Focus

The core of accessible menus is reliable focus management. The pattern above follows the “focus-in / focus-out” model:

  1. On open – move focus to the first focusable item inside the panel.
  2. While open – keep focus looping within the panel’s tabbable elements.
  3. On close – return focus to the element that opened the menu (the trigger).

If your mega‑menu loads additional content dynamically (e.g., a “Load more” button inside a column), you must update the list of focusable elements after the DOM change. A simple way is to call a refocus function:


Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
merbayerp profile image
Mustafa ERBAY

Useful article, but I think the example mixes two different patterns. A website navigation mega-menu generally shouldn’t trap focus like a modal, and using role="menu" also requires the full ARIA menu interaction model (arrow-key navigation, menuitem roles, etc.). For most site navigation, the WAI-ARIA Authoring Practices recommend a simpler disclosure pattern with semantic links instead. Also, WCAG 2.4.3 requires a logical focus order—not necessarily one that exactly matches the visual layout.

References: