Guides · August 19, 2023 · 8 min read
Mega Menus & Commerce Navigation in React
Building a React mega menu for commerce means solving state, accessibility, and performance together — this tutorial walks through a controlled, keyboard-accessible pattern that scales to a real category tree.
By Polo Themes
A React mega menu is best built as a small set of controlled, composable pieces — a trigger, a panel, and a shared open-state model — rather than one giant component that owns everything. Get the state model and keyboard behavior right first, and the visual layout (columns, promo tiles, featured products) becomes a styling problem you can iterate on freely. This tutorial builds a production-grade pattern from the state layer up, then covers the commerce-specific edges: deep category trees, mobile drawers, and performance under a large navigation payload.
Mega menus show up constantly in commerce navigation because a flat dropdown cannot represent a real catalog. A store with a few hundred SKUs across a dozen categories, each with sub-categories, featured collections, and seasonal promos, needs more surface area than a simple list. The trade-off is that mega menus are also one of the most commonly broken pieces of UI on the web — hard to keyboard-navigate, prone to flicker on hover, and often invisible to screen reader users. The good news is that the underlying interaction pattern is well understood; it just needs to be modeled deliberately in React rather than bolted together with a pile of onMouseEnter handlers.
Start With the State Model, Not the Layout
The single biggest source of mega-menu bugs is treating "which panel is open" as several independent pieces of local state — one boolean per menu item, scattered across sibling components. That approach breaks the moment you need "only one panel open at a time," which is true of essentially every real mega menu. Model it instead as one piece of state living in a shared parent: the id of the currently open menu item, or null when nothing is open.
A minimal version looks like this: a top-level MegaMenu component holds openId in useState, and passes openId plus a setOpenId callback down to each MenuItem via context or props. Each MenuItem compares its own id to openId to decide whether its panel renders. This single source of truth is what makes "hovering a new top-level item closes the previous panel and opens the new one" trivial — it falls out of the state model for free instead of requiring you to manually close every sibling.
Hover Intent: Don't Fire on Every Mouse Movement
A naive implementation opens the panel on mouseEnter and closes it on mouseLeave. In practice this causes two problems: panels flicker open and shut as a cursor passes over the nav on its way somewhere else, and moving diagonally from the trigger into the panel itself (which is often below and to the side) triggers a mouseLeave before the cursor reaches the content. Both are solved with a short delay, not with layout hacks.
- On mouseEnter, set a timeout (150 to 200ms is a reasonable default) before calling setOpenId — cancel it if the pointer leaves before it fires, so a quick pass-through never opens anything.
- On mouseLeave, set a similar close timeout rather than closing immediately, and cancel it if the pointer re-enters the trigger or the panel.
- Track hover state on the panel itself, not only the trigger, so moving the cursor from the trigger down into the panel counts as "still hovering the same menu item" rather than a leave-then-enter.
- Always clear pending timeouts on unmount to avoid a setState call on an unmounted component.
This "hover intent" pattern is worth writing once as a small custom hook (useHoverIntent or similar) that returns onMouseEnter/onMouseLeave handlers and hides the timeout bookkeeping. Every trigger and panel in the menu can then reuse it instead of re-deriving the timing logic per component.
Keyboard and Screen Reader Behavior
Hover is a secondary input method — a correct mega menu has to work fully from the keyboard, because that is how screen reader users, switch-device users, and plenty of sighted keyboard users navigate. The WAI-ARIA disclosure and menu patterns give a solid baseline, but most real commerce mega menus are closer to a disclosure pattern (a button that reveals a region) than a true ARIA menu, and that distinction matters: a true role="menu" pattern comes with strict arrow-key and Escape requirements that are easy to get subtly wrong, while a disclosure pattern (button + aria-expanded + a revealed region of ordinary links) is simpler to implement correctly and is what most shoppers actually expect — normal Tab order through real links, not an application-style menu.
- Each top-level trigger is a real button (not a div or span) with aria-expanded reflecting openId === item.id and aria-controls pointing at the panel's id.
- The panel itself gets a matching id and, if you want extra clarity for assistive tech, role="region" with an aria-label describing the category.
- Escape closes the currently open panel and returns focus to its trigger — never leave focus stranded inside a panel that just unmounted.
- Tab order should flow naturally from the trigger into the panel's links when open, and a closed panel's links must not be reachable by Tab at all (either unmount them or apply inert/tabIndex={-1} while closed).
- Don't suppress the browser's default focus outline with an unconditional outline: none — if you restyle focus rings, restyle them for every interactive element in the menu, not just some.
A quick manual test that catches most regressions: unplug the mouse, Tab through the entire header, and confirm you can open every top-level category, reach every link inside its panel, and close it with Escape without ever losing track of where focus is. If that walkthrough feels awkward for a sighted keyboard user, it will be worse for a screen reader user layering audio feedback on top.
Modeling a Real Commerce Category Tree
Commerce navigation is rarely a flat list — it's closer to a two- or three-level tree: top-level category, sub-categories, and sometimes a featured rail of products, collections, or a promo tile inside the panel. Resist the urge to hand-author this structure as deeply nested JSX. Model it as data — an array of top-level items, each with a children array and an optional featured slot — and render it with a recursive or mapped component. This keeps the layout logic (grid columns, section headings, a promo image slot) reusable across every category instead of duplicated per menu item, and it means the same data can drive both the desktop mega panel and the mobile drawer described below.
In a headless commerce setup, this navigation tree is usually a separate, cacheable read from your product/category API rather than something derived from live catalog data on every request. Fetch it once at a layout level, cache it aggressively (it changes far less often than inventory or pricing), and pass it down — don't have every MenuItem independently trigger its own data fetch on hover. If you're on Next.js, this is a natural fit for a cached server-side fetch at the root layout with a longer revalidate window than the rest of the storefront.
Mobile: A Drawer, Not a Squeezed-Down Mega Panel
Don't try to make the desktop mega panel responsive down to phone width — the column layout that makes sense at 1400px becomes unusable at 375px. Instead, treat mobile navigation as a genuinely different component: a full-height slide-in drawer with an accordion-style disclosure for each category, driven by the same data tree. This also sidesteps hover entirely, since touch devices don't have a meaningful hover state — every interaction on mobile should be an explicit tap, with a visible expand/collapse affordance (a chevron, not just color change) on each row.
A common and reasonable pattern is to render both trees in the DOM — mega menu for the desktop breakpoint, drawer for mobile — gated with CSS rather than a JS-based viewport check, so there's no layout flash while JavaScript figures out which one to show. If bundle size is a concern, the drawer and mega-panel internals can still be code-split so only the active one's chunk loads.
Performance: Keep the Nav Cheap Even When It's Big
A mega menu with promo images and a large category tree is one of the heaviest single components on a commerce homepage, and it renders on every page since it lives in the header. A few habits keep it from becoming a performance liability:
- Render only the open panel's contents when practical — if panels contain promo images or product cards, mounting all of them at once (even hidden with CSS) forces the browser to lay out and paint content nobody is looking at.
- Use next/image or an equivalent for any promo imagery inside panels, with explicit width/height to avoid layout shift when a panel opens.
- Memoize MenuItem so that opening one panel doesn't force every sibling to re-render — pass primitives (the open id, not a whole object) as props where you can, and wrap callbacks in useCallback so memoization actually holds.
- Avoid recreating the navigation data array on every render of a parent component — hoist it to a module-level constant or fetch it once and store it in a stable reference.
None of these are exotic optimizations — they're the same discipline you'd apply to any frequently-rendered, data-heavy component. The difference with a mega menu is that it sits in the header, so its performance profile affects every single page view rather than one route.
Putting It Together
A well-built mega menu ends up being a fairly small amount of actual logic: one piece of shared open-state, a hover-intent hook, a data-driven tree renderer, a disclosure-pattern accessibility layer, and a mobile drawer that shares the same data. Almost everything else is layout and visual design — column widths, promo tile placement, typography for category headings — which is exactly the kind of decision that's easier to make well when you're starting from a properly structured design file rather than guessing at spacing in code. If you're designing the navigation before you build it, our Figma UI kits include commerce-oriented navigation and header patterns worth adapting as a starting layout, even if you're shipping the final implementation as custom React rather than a themed storefront.
The pattern here scales from a simple two-column dropdown up to a genuinely large catalog with dozens of categories. What doesn't scale is skipping the state-model step and hoping hover handlers and CSS will hold a complex navigation together — that's the version that flickers, traps keyboard focus, and quietly excludes screen reader users. Build the state and accessibility layer first, and the visual mega menu you actually want to ship becomes a much smaller problem.
Frequently Asked Questions
Should a mega menu use role="menu" or a simpler disclosure pattern?
For most commerce navigation, a disclosure pattern (a button plus a revealed region of ordinary links) is the safer choice. True ARIA menu semantics come with strict arrow-key requirements that are easy to implement incorrectly, and shoppers generally expect normal link behavior — Tab, Enter, opening in a new tab with a modifier key — rather than application-menu keyboard conventions.
Do I need a UI library to build this, or can I write it from scratch?
You can build a fully accessible mega menu from scratch using the patterns in this guide — there's no hard dependency on a specific component library. If your team already leans on an unstyled primitives library for other disclosure/popover behavior in the app, reusing it for consistency is reasonable; the state model and accessibility requirements described here apply either way.
How should the mega menu handle a very deep category tree?
Past two or three levels, resist adding another nested panel — it becomes hard to navigate on both desktop and mobile. Instead, surface only the top two levels in the mega menu itself and let deeper categories live on a dedicated category or collection page, linked from the panel as a "view all in [category]" entry.
Does the navigation tree need to be fetched on every page load?
No — it should be one of the most heavily cached reads in a headless storefront, since categories change far less often than inventory or pricing. Fetch it once at a layout level with a long revalidation window rather than re-querying it per route or per component.