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:
-
Focus‑visible – you must not rely solely on
:focus-visiblebeing hidden by a custom outline removal. The indicator needs sufficient contrast (≥ 3:1 against adjacent colors) and must not be removed viaoutline:nonewithout a replacement. -
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 usetabindexcarefully 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>
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>
</>
);
}
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; }
}
Why this works
- The
:focus-visiblepseudo‑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
useEffectthat runs whenisOpentoggles. It moves focus to the first link, loops shift‑tab/tab at the boundaries, and returns focus to the trigger onEscape. - 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:
- On open – move focus to the first focusable item inside the panel.
- While open – keep focus looping within the panel’s tabbable elements.
- 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:
Top comments (1)
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: