Keyboard navigation and ARIA in Hyvä
A mega menu dropdown with multi-column subcategories is one of the most complex navigation patterns in e-commerce, and one of the most frequently implemented incorrectly when it comes to accessibility. With Alpine.js, the disclosure pattern instead of wrong menu roles, and clean keyboard control, a mega menu can be built that works equally well for mouse, keyboard and screen reader.
Table of contents
- 1. Why mega menus are so often not accessible
- 2. Foundation: state for nested menu items
- 3. Correct ARIA semantics: disclosure instead of menu role
- 4. Full keyboard navigation with arrow keys
- 5. Hover and click: timing without frustration
- 6. Focus behavior when leaving the panel
- 7. Mobile: switching to an accordion
- 8. Performance: rendering panel content only when needed
- 9. Mega menu approaches compared
- 10. Summary
- 11. FAQ
1. Why mega menus are so often not accessible
A mega menu shows a multi-column panel with subcategories, product images and sometimes additional promotional areas when a main navigation item opens. This complexity is exactly why mega menus are so often unusable for keyboard users and screen readers: developers frequently copy ARIA attributes from application menus like a file menu in a desktop application, even though a mega menu in website navigation is semantically something completely different.
The official WAI-ARIA Authoring Practices explicitly advise against using role="menu" and role="menuitem" for website navigation. These roles are meant for application menus, where arrow keys switch between options, not for links to other pages. A mega menu dropdown should instead be implemented as what is called the disclosure pattern: a button with aria-expanded that shows and hides a panel of ordinary links.
With Alpine.js this correct pattern is noticeably easier to implement than with the more complex menu roles, because no artificial capturing of arrow key navigation between links is needed. The following sections build a complete mega menu with correct semantics, keyboard control, focus behavior and a clean mobile rebuild.
2. Foundation: state for nested menu items
The state of a mega menu dropdown needs to know which main menu item is currently open, not how many are open. Only one panel should be visible at a time, otherwise multi-column content overlaps and the page becomes confusing. A single variable holding the ID of the open menu item is therefore simpler and more robust than a boolean per menu item.
This structure is registered as an Alpine.data() component on the enclosing <nav> element, so every main menu item accesses the same state through $data and automatically closes each other as soon as another item is opened.
document.addEventListener('alpine:init', () => {
Alpine.data('megaMenu', () => ({
openItemId: null,
isOpen(itemId) {
return this.openItemId === itemId;
},
toggle(itemId) {
this.openItemId = this.isOpen(itemId) ? null : itemId;
},
close() {
this.openItemId = null;
}
}));
});
The advantage of this centralized state management: clicking a second main menu item automatically closes the previously opened panel, because openItemId is overwritten, instead of two panels staying open at once. For a mega menu dropdown this exclusive behavior is almost always what is wanted.
3. Correct ARIA semantics: disclosure instead of menu role
The triggering button of every main menu item needs aria-expanded, dynamically bound to the open state, plus aria-controls pointing to the ID of the associated panel. The panel itself is an ordinary <div> with normal links, not a role="menu" structure. Screen reader users navigate through the panel like through any other page section, with Tab instead of arrow keys between the links.
This seemingly simpler semantics is in fact the solution recommended by the WAI-ARIA Authoring Practices for exactly this use case. A mega menu dropdown with this structure behaves predictably for screen reader users, because it behaves like any other expandable area on the page.
<nav x-data="megaMenu()" aria-label="Main navigation">
<ul class="flex gap-6">
<li>
<button
@click="toggle('women')"
@keydown.escape="close()"
:aria-expanded="isOpen('women')"
aria-controls="megamenu-panel-women"
class="font-semibold py-4"
>
Women
</button>
<div
id="megamenu-panel-women"
x-show="isOpen('women')"
x-transition
@click.outside="close()"
class="absolute inset-x-0 bg-white border-t border-slate-200 shadow-lg grid grid-cols-4 gap-6 p-8"
x-cloak
>
<!-- ordinary links, no role="menuitem" -->
<a href="/women/dresses" class="block py-1 hover:text-teal-700">Dresses</a>
</div>
</li>
</ul>
</nav>
The button is deliberately not an <a>, but a real <button> element, because it does not trigger navigation but only opens and closes a panel. This distinction between link and button is crucial for correct screen reader announcement: a button is announced as an interactive control, a link as a navigation target.
4. Full keyboard navigation with arrow keys
Even without the menu role, a good mega menu dropdown should offer optional arrow key support to allow experienced keyboard users faster navigation. It is important that this support exists in addition to normal Tab navigation, not as a replacement for it, since Tab must always work.
The escape key should close the menu in every open panel and set focus back to the triggering button. This behavior is an established pattern from every common UI library and is instinctively expected by keyboard users.
Alpine.data('megaMenu', () => ({
openItemId: null,
isOpen(itemId) { return this.openItemId === itemId; },
toggle(itemId) {
this.openItemId = this.isOpen(itemId) ? null : itemId;
},
closeAndReturnFocus(triggerId) {
this.openItemId = null;
this.$nextTick(() => {
document.getElementById(triggerId)?.focus();
});
},
// Arrow-right/left move focus between top-level triggers
focusNextTrigger(currentEl, direction) {
const triggers = [...document.querySelectorAll('[data-menu-trigger]')];
const index = triggers.indexOf(currentEl);
const nextIndex = (index + direction + triggers.length) % triggers.length;
triggers[nextIndex]?.focus();
}
}));
In the markup, @keydown.arrow-right="focusNextTrigger($event.target, 1)" and @keydown.arrow-left="focusNextTrigger($event.target, -1)" are added to every trigger button. This modulo calculation ensures navigation wraps back to the first item from the last one, which noticeably speeds up operation, especially with many main categories in a mega menu dropdown.
5. Hover and click: timing without frustration
On desktop devices, many customers expect a mega menu dropdown to also open on hover, not only on click. The problem: closing immediately when the mouse leaves often causes the panel to disappear while the customer moves diagonally toward the subcategory and briefly leaves the trigger zone in the process.
A short delay timer on the mouseleave event reliably solves this problem. It is important to reset the timer on every renewed mouseenter, so the panel does not disappear after the delay if the customer returns in time.
Alpine.data('megaMenuHover', () => ({
openItemId: null,
closeTimer: null,
openOnHover(itemId) {
clearTimeout(this.closeTimer);
this.openItemId = itemId;
},
scheduleClose() {
this.closeTimer = setTimeout(() => {
this.openItemId = null;
}, 200);
},
cancelClose() {
clearTimeout(this.closeTimer);
}
}));
A delay of around 200 milliseconds is common, because it is short enough not to feel sluggish, but long enough to catch brief mouse movements. Important: this hover logic should be completely disabled on touch devices, since only click interaction exists there anyway, and a mega menu dropdown needs to be triggered differently on touch than on desktop.
6. Focus behavior when leaving the panel
When the customer tabs through the open panel of a mega menu dropdown and tabs past the last link, the panel should close automatically, instead of staying open while focus already sits in the next navigation area of the page. Without this behavior, an open but functionally abandoned panel stays visible, which is confusing for sighted keyboard users.
The @focusout event combined with a check of whether the new focus target is still inside the panel elegantly solves this problem, without needing a separate event listener for every single link.
<div
x-show="isOpen('women')"
@focusout="if (!$el.contains($event.relatedTarget)) { close() }"
class="absolute inset-x-0 bg-white shadow-lg"
>
<!-- panel links -->
</div>
$event.relatedTarget contains the element that receives focus next. If this element is no longer inside the panel, according to $el.contains(), the mega menu dropdown closes automatically. This check reliably works for Tab navigation both forward and backward.
7. Mobile: switching to an accordion
A multi-column mega menu dropdown works well on desktop screens but is unusable on a smartphone screen. The established approach is to display the same data structure as a vertical accordion below a defined breakpoint, where every main menu item expands and collapses individually, without multi-column layout.
Since both representations use the same Alpine state, the underlying logic does not need to be duplicated. Only the CSS classes and the layout structure differ between the two breakpoints, controlled via Tailwind's responsive prefixes like lg:.
<!-- Same x-data, different markup per breakpoint -->
<div x-data="megaMenu()">
<!-- Desktop: multi-column panel, hidden below lg -->
<div class="hidden lg:block">
<!-- mega menu panel from section 3 -->
</div>
<!-- Mobile: vertical accordion, hidden from lg upward -->
<div class="lg:hidden">
<button @click="toggle('women')" :aria-expanded="isOpen('women')" class="w-full flex justify-between py-3">
Women
<svg :class="isOpen('women') ? 'rotate-180' : ''" class="w-4 h-4 transition-transform"><!-- chevron --></svg>
</button>
<div x-show="isOpen('women')" x-collapse>
<a href="/women/dresses" class="block py-2 pl-4">Dresses</a>
</div>
</div>
</div>
The x-collapse directive from the official collapse plugin animates the height of the accordion content cleanly, without needing a fixed height in the CSS. For a mega menu dropdown with subcategory lists of varying length, that is crucial, because a fixed height would either cut content off or create unnecessary empty space.
8. Performance: rendering panel content only when needed
A complete mega menu dropdown with many main categories can contain hundreds of links and images across all panels combined. If all panels are rendered into the DOM on initial page load, even while invisible, that noticeably extends time to interactivity, especially on mobile devices.
x-if instead of x-show for the less frequently used panels removes the content entirely from the DOM as long as it is not needed, only inserting it on first open. For the one or two most frequently used main categories, x-show still makes sense, because repeatedly recreating the DOM on every open causes unnecessary overhead.
<!-- Rarely opened category: only rendered into the DOM once actually opened -->
<template x-if="openItemId === 'sale'">
<div class="absolute inset-x-0 bg-white shadow-lg grid grid-cols-4 gap-6 p-8">
<!-- heavy panel content, images, many links -->
</div>
</template>
<!-- Frequently opened category: kept in the DOM, just toggled via x-show -->
<div x-show="openItemId === 'women'" class="absolute inset-x-0 bg-white shadow-lg">
<!-- panel content -->
</div>
This mixed strategy is a good compromise between initial load time and response speed on repeated opening. For a mega menu dropdown with a clear split between frequently and rarely used categories, this differentiation is almost always worth it.
9. Mega menu approaches compared
The technical implementation of a mega menu dropdown differs significantly between a naive and an accessible, performant solution.
| Aspect | Common mistake | Recommended mega menu pattern | Benefit |
|---|---|---|---|
| ARIA semantics | role="menu" for navigation links |
Disclosure with aria-expanded |
Matches WAI-ARIA Authoring Practices |
| Multiple open panels | Boolean flag per menu item | A single openItemId variable |
Automatically exclusive opening |
| Focus on leaving | Panel stays open after tabbing out | @focusout with contains() check |
No orphaned open panel |
| Mobile display | Same multi-column layout enforced | Accordion with x-collapse |
Usable on small screens |
| Initial rendering | All panels in the DOM immediately | x-if for rarely used panels |
Shorter time to interactivity |
The biggest lever is ARIA semantics: as soon as a mega menu dropdown is treated as a disclosure pattern instead of an application menu, most keyboard and screen reader problems solve themselves, because standard link and button behavior is used instead of artificially recreating it.
Mironsoft
Hyvä theme development and accessible navigation for Magento
A mega menu that truly works for everyone?
We build navigation patterns, mega menus and other UI components as Alpine.js components with correct ARIA semantics, full keyboard operability and a clean mobile rebuild.
Mega menus & navigation
Disclosure pattern instead of wrong menu roles, fully keyboard operable
Accessibility audit
Reviewing existing navigation patterns against WCAG 2.1
Mobile rebuild
Clean accordion rebuild without duplicated state logic
10. Summary
An accessible mega menu dropdown is built on the disclosure pattern instead of incorrectly applied menu roles: a button with aria-expanded that opens a panel of ordinary links. A central openItemId variable keeps only one panel open at a time, @focusout closes the panel automatically once focus leaves it, and x-collapse handles the switch to a mobile accordion without duplicated logic.
Performance and accessibility are not at odds for a mega menu dropdown: x-if for rarely used panels reduces the initial DOM size, while correct ARIA semantics require no extra code compared to an incorrectly implemented menu role solution. Whoever thinks about both aspects from the start avoids costly rework after an accessibility audit.
Accessible Mega Menu Dropdown with Alpine.js — The essentials at a glance
ARIA semantics
Disclosure pattern with aria-expanded, no role="menu" for navigation links.
State design
A single openItemId variable automatically keeps only one panel open.
Focus behavior
@focusout with a contains() check automatically closes the panel when leaving it.
Mobile & performance
x-collapse for the accordion, x-if for rarely used panels.