GODRICH

9 mega menu ui patterns, link list to site map

A mega menu ui opens a screen-wide panel from one top-row item, turning the site into one readable map.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order is not a popularity list; it follows the weight the menu has to carry. The first three are desktop ways to unfold information. Hang a full-width panel on one row (01), push the hierarchy sideways (02), and swap bundles inside one panel (03). The next two meet the scroll. A header that folds as the page scrolls past (04) and a sub-nav that pins itself and announces the current section (05). Sixth and seventh answer the narrow screen. The burger folds into an X over a fullscreen sheet (06) and a drawer slides in from the right edge (07). The last two are the remaining jobs of a menu: finding and being notified. An icon unfolds into an input (08) and a bell counts what you haven't read (09). From the first cell to the last, the menu stops being a list of links and becomes the cockpit of the site. Neighboring pieces on the same axis live in hamburger menu animations and tab menu transitions, and the select-shaped side of choosing is covered in custom select dropdowns.

01Full-width mega panel that opens on hover

Rest the pointer on a top-level item and a panel as wide as the screen drops in, carrying three category columns and one highlight card. The opening lands as a six-pixel translateY plus a single cut, and the columns rise with a stagger after it, so eighteen of the twenty-four frames move inside the two-second loop. It fits shopping and SaaS headers with three or more product families.

:hovergrid-template-columnsaria-expanded
trigger.setAttribute('aria-haspopup', 'true');
trigger.setAttribute('aria-expanded', 'false');

function open() { stopDemo(); root.classList.add('is-open'); trigger.setAttribute('aria-expanded', 'true'); }
function close() { stopDemo(); root.classList.remove('is-open'); trigger.setAttribute('aria-expanded', 'false'); }

trigger.addEventListener('mouseenter', open);
trigger.addEventListener('focus', open);
trigger.addEventListener('click', function () { root.classList.contains('is-open') ? close() : open(); });
root.addEventListener('mouseleave', close);
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });

02Multi-level dropdown whose submenus slide right

Hover an arrowed item in the first list and the second level slides in from the right; an arrowed item inside it carries a third level. Submenus are driven purely by CSS :hover and :focus-within, so Tabbing into an item opens it at the same instant, while JS only guards the top-level open state and aria-expanded. It belongs in docs and help centers whose categories stack two or three levels.

translateXaria-haspopup:focus-within
// Submenus sit to the right of their parent li — opened by hover/:focus-within alone
.mls__l2,
.mls__l3 {
  top: 0;
  left: 100%;
  opacity: 0;
  visibility: hidden;
  transform: translateX(12px);
  transition: opacity $dur-quick $easing, transform $dur-quick $easing, visibility 0s linear $dur-quick;
}
.mls__i--parent:hover > .mls__l2,
.mls__i--parent:focus-within > .mls__l2,
.mls__i--parent:hover > .mls__l3,
.mls__i--parent:focus-within > .mls__l3 {
  opacity: 1;
  visibility: visible;
  transform: translateX(0);
}

03Mega menu that swaps panels by tab

Press a tab inside the panel and the card bundle swaps in place within a single frame, while only the pressed tab reads as selected. The swap slices the two bundles apart with a steps(1, end) cut, and the fresh cards pop on a translateY stagger right after, which lifts the moving-frame count by more than half compared to a cut alone. It fits store navigation that must show one category from a few angles.

flex: 0 0 autosteps(1, end)aria-selected
function select(i) {
  tabs.forEach(function (t, k) {
    t.setAttribute('aria-selected', k === i ? 'true' : 'false');
    t.tabIndex = k === i ? 0 : -1;
    sets[k].hidden = k !== i;
    if (k === i && !root.classList.contains('is-demo')) {
      sets[k].classList.remove('is-pop');
      void sets[k].offsetWidth;
      sets[k].classList.add('is-pop');
    }
  });
}

04Header that folds smaller as you scroll

As the page scrolls past, the empty bar background and its contents shrink together, and the page is pulled up by that much, then it all restores on the way back. Instead of animating height, the empty bar folds with scaleY, the contents with scale, and the page with translateY, so one compacting pass costs zero reflow, and a single sentinel at the top of the feed decides the flip. It belongs on long-scrolling lists and magazine-style pages.

scaleYIntersectionObservertransform-origin
// The whole shrink is transforms, so reflow stays at zero.
var ns = document.querySelector('.ns');
var feed = ns.querySelector('.ns__feed');
var head = ns.querySelector('.ns__head');
new IntersectionObserver(function (entries) {
  head.classList.toggle('is-compact', !entries[0].isIntersecting);
}, { root: feed }).observe(ns.querySelector('.ns__sentinel'));

05Sticky sub-nav that announces the current section

While sections scroll past, the category bar stays pinned, and whenever the section on screen changes, the highlight moves to its chip. An IntersectionObserver rooted at the scroll box picks whichever section sits closest to the center of the view and rewrites the chips' aria-current. Manuals and landing pages where one page holds several sections are its home.

position: stickyIntersectionObserveraria-current
var io = new IntersectionObserver(function (entries) {
  var w = win.getBoundingClientRect();
  var mid = w.top + w.height / 2;
  var best = null, gap = Infinity;
  entries.forEach(function (e) {
    if (!e.isIntersecting) return;
    var r = e.boundingClientRect;
    var d = Math.abs((r.top + r.bottom) / 2 - mid);
    if (d < gap) { gap = d; best = e.target; }
  });
  if (best) mark(best);
}, { root: win, threshold: 0.5 });

06Fullscreen overlay where the burger folds into an X

The three lines swing apart and fold into an X, and links climb one by one onto the sheet that covers the whole screen. While the sheet is open, the keyboard focus stays inside it. The bar morph is a translateY plus rotate pair; each link arrives on its own delay; and the result reads as a signboard rather than a door, which suits the primary menu of mobile and portfolio sites.

rotatetranslateYaria-modal
ov.addEventListener('keydown', function (e) {
  if (e.key !== 'Tab' || links.length === 0) return;
  var first = links[0], last = links[links.length - 1];
  if (e.shiftKey && document.activeElement === first) {
    e.preventDefault(); last.focus({ preventScroll: true });
  } else if (!e.shiftKey && document.activeElement === last) {
    e.preventDefault(); first.focus({ preventScroll: true });
  }
});

07Side drawer that slides in from the right

Press the button and a panel slides in from the right edge while the rest of the screen dims and goes untouchable; when it closes, focus returns to the button you pressed. The lock is a single inert attribute. With inert on the page, links behind the veil stay visible yet Tab and the screen reader stop reaching them, and closing the drawer reverses both sides, which suits cart and account menus.

translateXinertaria-hidden
function setOpen(on) {
  root.classList.toggle('is-open', on);
  page.inert = on;                                    // while open, the page loses focus
  page.setAttribute('aria-hidden', on ? 'true' : 'false');
  drawer.inert = !on;
  drawer.setAttribute('aria-hidden', on ? 'false' : 'true');
  openBtn.setAttribute('aria-expanded', on ? 'true' : 'false');
  (on ? closeBtn : openBtn).focus({ preventScroll: true });
}

08Search that unfolds from an icon into an input

Press the magnifier and a pill-shaped input unfolds from the icon's own spot, and after a few keystrokes, jump links appear just beneath it. Instead of growing wider, the input is always full width and simply stays covered from the right by clip-path: inset, so unfolding never triggers a layout pass. Docs and blog headers where top space is scarce are its home.

clip-pathinsetaria-expanded
$clip-shut: inset(0 calc(100% - 40px) 0 0 round 999px);   // only the 40px icon slot shows
$clip-open: inset(0 0 0 0 round 999px);                    // everything shows

.search {
  clip-path: $clip-shut;
  transition: clip-path $dur-base $easing;
}
.frame.is-open .search { clip-path: $clip-open; }

09Notification badge menu with live read state

The figure pinned to the corner of the bell counts unread items, and reading one in the panel wipes its dot while the figure drops that very instant. The badge stacks its three figures and swaps them with a steps(1, end) cut, so no frame ever shows two numbers overlapping, and an off-screen aria-live region speaks the new count. It is the notification center at the top of an app.

steps(1, end)aria-livetranslateY
function sync() {
  var n = unread().length;
  root.classList.toggle('is-clear', n === 0);
  badge.setAttribute('data-n', String(n));
  live.textContent = live.dataset.fmt.replace('{n}', String(n));
}

Where it breaks — traps

Three places stopped me this time.

The first was the width of 02. Even with the three levels narrowed to 112, 96, and 84px, the phone render measured 329px wide. The culprit was the slide itself. Each submenu hangs at left: 100% of its parent item, and a sliding parent's transform: translateX(12px) applies to its absolutely positioned children too, so the moment L2 and L3 move together, the menu reaches 24px farther than its resting size. The media query now uses 108, 92, and 80px, which lands 12.8 + 280 + 24 at 316.8px. A value that only overflows in the first instant of opening is one you catch by looking, not by reading layout numbers. Absolutely positioned lists also drop out of the parent's height math, so reserving vertical room at the root is the safer habit.

The second was the poster frame. The gallery preview picks the frame that differs most from the one before it, and in any open-and-close panel the closing moment tends to beat the opening one, because the titles and cards that were absent at opening are present at closing. Fading the close out slowly changed nothing. This site's measurement counts a pixel as moved when its largest channel difference passes 12, and a cream panel over an ink stage stays above that bar even at half alpha, so the area refused to shrink. The close is now a clip-path: inset wipe instead of an opacity change, which splits what one frame has to absorb. I ran the wipe downward from the head row, so the panel leaves the screen in reading order. The posters of 01, 03, and 09 recovered their open scenes this way.

The third was the wipe refusing to move in 09 at first. One of the panel's animations runs on steps(1, end) timing, and a clip-path placed in the same keyframes got stair-stepped with it, collapsing the wipe into a single cut. Moving clip-path into the eased sibling animation fixed it frame by frame. Steps cut values apart; they do not sweep across them. For the same reason, stagger delays belong in individual animation-* properties, since the animation: shorthand resets them to zero. The password that opens these nine folders is kes5pp8b, and every number quoted above sits untouched in the 측정.json files of the run folder.

Accessibility

All nine stop their auto loop under prefers-reduced-motion: reduce and keep a static scene instead. 01, 02, and 03 stand open, 04 returns to the tall header, and 05 sets the bar back on its post under the title. 06 shows the sheet open with the bars folded into an X, 07 keeps the drawer open, 08 keeps the input unfolded, and 09 keeps its three unread rows. The door is the animation; the state of the door is a value, so turning motion off loses no information. Every open verdict lands in aria-expanded, and aria-haspopup is attached once by script. Every demo that moves focus calls focus({ preventScroll: true }), because these demos sit in the home listing inside iframes and an unguarded focus call drags the parent page down.

Contrast figures below were computed by hand with the WCAG relative luminance formula for the exact pairs this piece uses.

Where Text Background Ratio
01·03·08 body ink / white panel #17141a #ffffff 18.24:1
01 highlight card white / blue #ffffff #2f6df6 4.53:1
02·07·09 ink / cream panel #17141a #fff7e6 17.11:1
04 ink / yellow stage #17141a #ffd23f 12.63:1
05 highlight chip cream / ink #fff7e6 #17141a 17.11:1
06 big links cream / overlay #fff7e6 #241f2d 15.05:1
Mouse Keyboard Where state lives
01 Hover or press the trigger Focus opens, Esc closes trigger aria-expanded
02 Hover an arrowed item Tab enters submenus, Esc closes all each parent button aria-expanded
03 Press a tab Arrow keys move tabs, Esc closes tab aria-selected, panel hidden
04 Scroll the feed The scroll box itself takes focus the header's .is-compact class
05 Scroll past sections Chip anchors jump to sections chip aria-current
06 Press the button Tab cycles inside the sheet, Esc closes sheet aria-modal, button aria-expanded
07 Press button, overlay or close Page is inert, Tab stays in the drawer, Esc closes drawer and page aria-hidden
08 Press the magnifier and type Esc collapses, focus returns to the button button aria-expanded
09 Press bell, rows or mark-all Focus lands on the first unread row, Esc closes the count sentence in aria-live

The verdict rules follow the MDN page on aria-haspopup and the IntersectionObserver reference. Neighbors on the same axis include the floating dock navigation, the sticky header, and the sidebar drawer transitions.

FAQ

When should I not use a mega menu

When the item count will never pass six or seven. The panel's whole value is the map it shows at once, and a small map is slower than a list. Below that line, the 02 flyout, or a custom select dropdown inside a form, answers faster. Decide by the size of the reader's map, not by how much you have to say.

Hover or click — what decides it

If the panel will cover half the screen or more, open on click; below that, hover is fine. A hover-open panel borrows attention from anyone just passing by, and it must close the moment the pointer leaves the whole group, as 01 does, or half-open states linger. 01 also accepts focus and click as open triggers, which covers touch and keyboard without a second component.

How do these patterns change on mobile

Unfolding changes into covering. The desktop mega panel usually becomes the 06 fullscreen sheet or the 07 drawer on a narrow screen, while the 08 search starts from an icon and already fits. What must survive the swap is not the motion but the state contract: aria-expanded, inert, and focus returning to the trigger. Keep the contract, and it is still the same menu on a smaller stage.

Enter the archive password

The password is inside this article. You will find it as you read.