Mega Menu in React: Full-Width Dropdown With Categories and Images
Build a full-width mega menu in React with category columns, images, and keyboard nav - no library needed. Tailwind CSS, accessible, and production-ready.
What a Mega Menu Actually Is (and When You Need One)
A mega menu is a dropdown that expands to full viewport width and organizes links into structured columns - with headings, icons, images, and sometimes featured content. Regular dropdowns can't handle more than a dozen links without becoming unusable. Mega menus solve that.
You see them everywhere: e-commerce sites with product categories, SaaS apps with a sprawling feature set, documentation portals with dozens of sections. If your top-level nav item has more than roughly 8 children, you've already hit the ceiling of a standard dropdown. That's when you reach for a mega menu.
Honestly, most developers put this off too long. They try to cram everything into a standard <ul> dropdown, the UX suffers, and then they spend three times the effort rebuilding it properly six months later. Get the structure right once and you won't touch it again.
One more thing - mega menus in React aren't hard, but they do require you to think carefully about three things: state management (which panel is open), focus trapping and keyboard navigation for accessibility, and CSS for the full-width layout. We'll cover all three in detail below.
Project Setup: Tailwind, React 18, and Zero Extra Libraries
You don't need a library for this. We're building from scratch with React 18 and Tailwind CSS v3. If you're on Next.js 14+ with the App Router, everything here works exactly the same - just drop components into your components/nav/ directory.
Quick aside: Tailwind's group and group-hover utilities are the secret weapon here. They let parent state drive child visibility without any JS overhead for the hover case. For keyboard and click-to-open behavior you'll still need state, but the hover path can be pure CSS.
Here's the base file structure you'll end up with:
``
src/
components/
nav/
MegaMenu.tsx # top-level nav wrapper
MegaMenuPanel.tsx # the dropdown panel
NavItem.tsx # individual top-level nav trigger
megaMenuData.ts # your nav data config
``
Start with your data shape. Defining it first forces you to think about the content before you write a single JSX tag, which saves enormous rework later:
``ts
// megaMenuData.ts
export type NavLink = {
label: string;
href: string;
description?: string;
image?: string;
};
export type NavCategory = {
heading: string;
links: NavLink[];
};
export type NavItem = {
label: string;
href?: string;
categories?: NavCategory[];
featured?: NavLink;
};
export const NAV_ITEMS: NavItem[] = [
{
label: 'Components',
categories: [
{
heading: 'Styles',
links: [
{ label: 'Glassmorphism', href: '/glassmorphism', description: 'Frosted-glass cards and panels' },
{ label: 'Neumorphism', href: '/neumorphism', description: 'Soft extruded UI surfaces' },
{ label: 'Neobrutalism', href: '/neobrutalism', description: 'Bold borders, raw energy' },
],
},
{
heading: 'Tools',
links: [
{ label: 'Gradient Generator', href: '/tools/gradient-generator' },
{ label: 'Box Shadow', href: '/tools/box-shadow-generator' },
],
},
],
featured: {
label: 'Browse all components',
href: '/',
image: 'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=400&q=80',
description: 'Over 200 production-ready components across 10 style systems.',
},
},
{ label: 'Templates', href: '/templates' },
{ label: 'Pricing', href: '/pricing' },
];
``
Building the MegaMenuPanel Component
The panel is the most important piece. It needs to span 100% of the viewport width (not the nav container width - that's the most common mistake), align to the top of the page below the nav bar, and render category columns next to a featured card.
Here's the full panel component:
``tsx
// MegaMenuPanel.tsx
import { NavCategory, NavLink } from './megaMenuData';
import Link from 'next/link';
import Image from 'next/image';
interface MegaMenuPanelProps {
categories: NavCategory[];
featured?: NavLink;
onClose: () => void;
}
export function MegaMenuPanel({ categories, featured, onClose }: MegaMenuPanelProps) {
return (
// full-width: position fixed, pin to left edge, span 100vw
<div
role="region"
className="fixed left-0 right-0 z-50 bg-white border-t border-gray-100 shadow-2xl"
style={{ top: 64 }} // match your nav height in px
>
<div className="max-w-7xl mx-auto px-6 py-8 grid grid-cols-[1fr_280px] gap-12">
{/* Category columns */}
<div className="grid grid-cols-3 gap-8">
{categories.map((cat) => (
<div key={cat.heading}>
<p className="text-xs font-semibold uppercase tracking-widest text-gray-400 mb-4">
{cat.heading}
</p>
<ul className="space-y-1">
{cat.links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
onClick={onClose}
className="group flex flex-col gap-0.5 rounded-lg px-3 py-2 hover:bg-gray-50 transition-colors"
>
<span className="text-sm font-medium text-gray-900 group-hover:text-violet-600">
{link.label}
</span>
{link.description && (
<span className="text-xs text-gray-500">{link.description}</span>
)}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
{/* Featured card */}
{featured && (
<Link
href={featured.href}
onClick={onClose}
className="group rounded-xl overflow-hidden border border-gray-100 hover:border-violet-200 transition-colors"
>
{featured.image && (
<div className="relative h-40 w-full">
<Image src={featured.image} alt={featured.label} fill className="object-cover" />
</div>
)}
<div className="p-4">
<p className="font-semibold text-gray-900 group-hover:text-violet-600 text-sm">
{featured.label}
</p>
{featured.description && (
<p className="text-xs text-gray-500 mt-1">{featured.description}</p>
)}
</div>
</Link>
)}
</div>
</div>
);
}
``
That style={{ top: 64 }} is intentional. You could use a CSS variable or a Tailwind arbitrary value like top-16, but hardcoding 64px against your nav height makes the relationship explicit. If you change your nav height later, you'll know exactly where to update it.
Worth noting: position: fixed with left-0 right-0 is what gives you the full-width breakout even when your nav container has a max-w-7xl constraint. Trying to do this with position: absolute on a contained parent is a path of pain - just use fixed.
Wiring Up State, Keyboard Navigation, and Click-Outside Closing
Now the MegaMenu wrapper. This is where you manage which item is open, handle the Escape key, and close the panel when users click outside. These three behaviors are what separate a real nav from a demo:
``tsx
// MegaMenu.tsx
'use client';
import { useRef, useState, useEffect, useCallback, KeyboardEvent } from 'react';
import { NAV_ITEMS } from './megaMenuData';
import { MegaMenuPanel } from './MegaMenuPanel';
import Link from 'next/link';
export function MegaMenu() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const navRef = useRef<HTMLElement>(null);
const close = useCallback(() => setOpenIndex(null), []);
// Close on Escape
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape') close();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [close]);
// Close on outside click
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (navRef.current && !navRef.current.contains(e.target as Node)) {
close();
}
};
if (openIndex !== null) document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [openIndex, close]);
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>, index: number) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setOpenIndex(openIndex === index ? null : index);
}
if (e.key === 'ArrowRight') {
const next = (index + 1) % NAV_ITEMS.length;
(navRef.current?.querySelectorAll('button, a')[next] as HTMLElement)?.focus();
}
if (e.key === 'ArrowLeft') {
const prev = (index - 1 + NAV_ITEMS.length) % NAV_ITEMS.length;
(navRef.current?.querySelectorAll('button, a')[prev] as HTMLElement)?.focus();
}
};
return (
<>
{/* Overlay - dims the page behind the open panel */}
{openIndex !== null && (
<div
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
onClick={close}
aria-hidden="true"
/>
)}
<nav ref={navRef} aria-label="Main navigation" className="relative z-50">
<ul className="flex items-center gap-1" role="menubar">
{NAV_ITEMS.map((item, i) => (
<li key={item.label} role="none">
{item.categories ? (
<button
role="menuitem"
aria-haspopup="true"
aria-expanded={openIndex === i}
onClick={() => setOpenIndex(openIndex === i ? null : i)}
onKeyDown={(e) => handleKeyDown(e, i)}
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 rounded-lg hover:bg-gray-100 transition-colors flex items-center gap-1"
>
{item.label}
<svg
className={w-4 h-4 transition-transform ${openIndex === i ? 'rotate-180' : ''}}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
) : (
<Link
href={item.href!}
role="menuitem"
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 rounded-lg hover:bg-gray-100 transition-colors block"
>
{item.label}
</Link>
)}
{openIndex === i && item.categories && (
<MegaMenuPanel
categories={item.categories}
featured={item.featured}
onClose={close}
/>
)}
</li>
))}
</ul>
</nav>
</>
);
}
``
The overlay <div> behind the panel is doing a lot of work for you. It catches clicks anywhere outside the panel, closes the menu visually via the onClick={close} handler, and also adds a subtle backdrop-blur-sm that draws focus to the open panel. That last part is a detail most implementations skip - and it makes a real difference in how polished the result feels.
In practice, the ArrowLeft/ArrowRight keyboard handling above is a simplified version. A fully WCAG 2.1 AA compliant mega menu also needs Tab and Shift+Tab to cycle through links inside the open panel, and focus should return to the trigger button when the panel closes via Escape. For most teams, start here and layer in the full focus management once the structure is solid.
That said, the aria-haspopup="true" and aria-expanded attributes are non-negotiable from day one. Screen readers announce the button state to users - skipping those is the most common accessibility failure in nav components.
Animations: Entry Transitions Without a Library
The panel snapping in with no animation feels jarring. You want a 150ms fade + translate on entry. You don't need Framer Motion for this - a single Tailwind animation class does it cleanly in 2026.
Add this to your tailwind.config.js:
``js
// tailwind.config.js
module.exports = {
theme: {
extend: {
keyframes: {
'menu-in': {
'0%': { opacity: '0', transform: 'translateY(-8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'menu-in': 'menu-in 150ms ease-out forwards',
},
},
},
};
`
Then add animate-menu-in to your panel's root <div>`. Done. The panel slides down 8px and fades in from opacity 0 - subtle but effective.
If you do want Framer Motion, wrap the panel in <AnimatePresence> and use motion.div with initial={{ opacity: 0, y: -8 }}, animate={{ opacity: 1, y: 0 }}, exit={{ opacity: 0, y: -8 }}, and transition={{ duration: 0.15 }}. The exit animation (which you can't get with pure CSS keyframes without extra JS) is the main reason to pull in the library. For a nav, it's usually not worth the bundle size. Look, users navigate faster than any exit animation runs anyway.
One thing worth mentioning: respect prefers-reduced-motion. Wrap your animation class conditionally:
``tsx
const prefersReduced =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
<div className={prefersReduced ? '' : 'animate-menu-in'}>
`
Or use the Tailwind motion-reduce: variant if you're on v3.3+: motion-reduce:animate-none animate-menu-in`.
Mobile: Collapse Into an Accordion
Mega menus don't translate to mobile. On screens narrower than 768px, you want a slide-in drawer or stacked accordion - not a full-width overlay that covers the whole phone screen. The cleanest approach is to render completely different markup based on screen size, controlled by a single isMobile state.
``tsx
// In MegaMenu.tsx, add:
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(max-width: 767px)');
setIsMobile(mq.matches);
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
// Then render MobileNav vs DesktopNav based on isMobile
``
The mobile accordion is simpler - a vertically stacked list of buttons that expand inline. You don't need the position: fixed trick there, and you don't need the overlay. Just max-h-0 overflow-hidden collapsing to max-h-screen with a transition:
``tsx
<div
className={overflow-hidden transition-all duration-200 ease-out ${
openIndex === i ? 'max-h-[500px]' : 'max-h-0'
}}
>
{/* accordion content */}
</div>
``
Worth noting: the max-h transition trick has a quirk - the transition timing feels uneven if content height varies a lot. A fixed max-h-[500px] with overflow-y-auto inside is more predictable than trying to animate to max-h-screen. Set the max-height to just above your tallest realistic content.
If you want to see how this pattern plays out in real design systems, check out the Empire UI templates - several of the SaaS layouts ship with pre-built responsive nav bars that handle exactly this mobile/desktop split. You can also reference the cyberpunk and neobrutalism style variants to see mega menus in radically different visual treatments.
Performance, SEO, and the Last Details
Search engine crawlers handle mega menus just fine in 2026 as long as your links are real <a> tags or Next.js <Link> components - which they are in our implementation. Hidden-via-CSS links (using display: none or visibility: hidden) may not be indexed. Since we're using conditional rendering with React state (openIndex !== null), the links are not in the DOM when the panel is closed. That's actually better for crawlers than CSS visibility tricks, and Googlebot handles client-side rendering well.
For performance: the panel component renders on demand, so you're not paying for it on initial paint. That said, if your nav includes images in the featured card, preload them. Add a <link rel="preload"> for critical nav images in your <head>, or use Next.js <Image priority> on the featured card's image.
One last detail that separates polished from amateur: the top: 64 offset on the panel assumes your nav bar is always 64px tall. If you're using a sticky nav that shrinks on scroll (a common pattern), that offset becomes a dynamic value. Pass it as a prop or compute it from a ref:
``tsx
const navRef = useRef<HTMLElement>(null);
// in the panel:
const navHeight = navRef.current?.getBoundingClientRect().height ?? 64;
<div style={{ top: navHeight }}>...</div>
``
The whole thing - panel, wrapper, mobile accordion, animations - comes in around 180 lines of TSX. No library dependencies beyond React and Tailwind. It's the kind of component you write once per project and never think about again, which is exactly what Empire UI's approach is built around: skip the setup tax and get to building what actually matters in your product.
FAQ
No. The pattern in this guide uses only React state and Tailwind - no external dependencies. Radix's NavigationMenu primitive is solid if you want full WCAG compliance out of the box, but it adds ~12kb to your bundle and the API surface takes time to learn.
Use position: fixed with left: 0; right: 0 on the panel element. This breaks it out of any parent's overflow or width constraints. Then center the inner content with a max-w-7xl mx-auto wrapper.
Not in 2026 - Googlebot renders JavaScript and indexes links inside React-state-gated components. Just make sure links are real <a> or <Link> tags, not buttons with JS redirects.
Use role="menubar" on the nav list, role="menuitem" with aria-haspopup="true" and aria-expanded on each trigger button, and role="region" on the open panel. Escape should close the panel and return focus to the trigger.
