Mega Menu
A wide, multi-column panel that exposes a whole section of a deep site at once. The engineering is entirely in the timing.
Also called Flyout, Navigation Menu, Mega Nav, Drop-down Nav — in this system all of them are Mega Menu.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Sweep the pointer across the bar, then take the diagonal down to a link in the far column. At 0 / 0 the menu flickers on the way in and dies on the way out; at 400 / 700 it feels stuck. The default sits where neither happens.
The diagonal problem
The pointer leaves the trigger before it reaches the panel. Whether the menu survives that gap is decided entirely by the close delay.
Closing the instant the pointer leaves the trigger punishes the most natural path there is — a straight line to the target.
A 240ms grace period covers the crossing. Entering the panel cancels the pending close outright, so the timer never races a user who has arrived.
The featured slot
One promoted item at the end of the row. It earns its place by being the thing you would otherwise buy an ad for — and it must never push the actual navigation below the fold.
Not every item needs a panel
Pricing is one page. Giving it a panel for the sake of symmetry teaches users that the chevron means nothing.
On mobile it is not a mega menu
No hover, no room for columns. The same information architecture becomes a drawer with one accordion section per top-level group.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
“Products” open. Four triggers, three labelled columns, and one featured slot — everything below the bar is a single panel, not four stacked menus.
- Trigger36px tall, 12px inline padding
A real button, not a hover target. It opens on click and on Enter, which is the only reason the component works on touch and on a keyboard.
- Chevron13px, rotates 180°
Present only on triggers that actually have a panel. A chevron on a plain link teaches users the affordance means nothing.
- Open delay120ms
Long enough that a pointer crossing the bar on its way elsewhere never triggers a panel; short enough that a deliberate hover feels immediate. Zero when a panel is already open.
- Close delay240ms
The grace period that covers the diagonal from the trigger to the far column. Entering the panel cancels it outright.
- Panel offset6px below the bar
Close enough to read as attached; far enough that the trigger’s focus ring is not clipped. The pointer crosses the gap faster than the close delay.
- Columnmin 11rem, 32px gutter
The gutter is wider than the row gap inside a column, so the eye reads down each column rather than across the panel.
- Column heading11px overline, muted
This is the component. Headings are what turn sixty links into three decisions; without them the panel really is the wall of choice Hick’s law warns about.
- Item description12px, one line
Optional, and it should stay optional. Descriptions on every item double the panel height and stop it being scannable.
- Featured slotinset surface, 16px
A different background so it reads as promotion rather than as another category. Last in the row, so it never delays the navigation it sits beside.
- Panel elevation--shadow-e4
Above the page, below a dialog. The panel is transient navigation, not a surface the user is meant to settle into.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-overlay | — | Panel background |
| --ds-surface-inset | — | Featured slot |
| --ds-border | — | Panel edge |
| --ds-layer-hover | — | Active trigger and item hover |
| --ds-fg-muted | — | Column headings and descriptions |
Spacing
| Token | Value | Used for |
|---|---|---|
| padding | Panel inset | |
| column gap | Between columns |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-xl | — | Panel corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e4 | — | Panel elevation |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-overline | Column headings |
Motion
| Token | Value | Used for |
|---|---|---|
| openDelay | Hover intent in | |
| closeDelay | Grace period out |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Label gap | Min width | Max width | When to use |
|---|---|---|---|---|---|---|---|
| Trigger | 36px | 0 12px | — | 4px | — | — | Label plus chevron. Sits inside a 56–64px bar. |
| Panel | — | 20px | --radius-xl | — | — | the content container | Full container width. Never wider than the bar it hangs from. |
| Column | — | — | — | 32px | 11rem | — | Two to four columns. Five is a wall. |
| Item | 32px | 6px 8px | — | — | — | — | Label only. |
| Item with description | 48px | 6px 8px | — | — | — | — | Two lines. Use sparingly. |
| Featured | — | 16px | — | — | 13rem | — | One promoted item, last in the row. |
| Panel height | max 70vh | — | — | — | — | — | Past this the panel needs a scrollbar, which is the point at which it has too much in it. |
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Column headings are 11px muted text and still need 4.5:1. They are the most important text in the panel — being small is not an exemption.
- The panel must reach 3:1 against the page behind it. Its own border does that job in dark themes, where the shadow does almost nothing.
- The open trigger must be distinguishable from its siblings by more than the chevron rotation, which is invisible to anyone not watching for it.
Keyboard
| Tab | Moves through the triggers, then into the open panel’s links. |
| Enter / Space | Opens or closes the panel for the focused trigger. |
| ↓ | Opens the panel and moves into it. |
| ← → | Moves between triggers along the bar. |
| Escape | Closes the panel and returns focus to its trigger. |
| Tab past the last link | Closes the panel. Leaving it open behind the next focused element is disorienting. |
Screen readers
- Announced as "Products, button, collapsed", then on open the panel’s headings and lists in order.
- The column headings must be real headings. To a screen reader the visual grouping does not exist, and without them the panel is sixty links in a row.
- Do not render the panel’s contents when it is closed. Sixty hidden links are still in the tab order and the accessibility tree unless they are properly removed.
- Keep the panel immediately after its trigger in the DOM, so the reading order matches the visual one.
Focus & touch
- Focus never moves on hover — only on click, Enter or ↓. A panel that steals focus because a pointer drifted across the bar is far worse than one that opens a moment late. Escape returns focus to the trigger, and Tabbing out of the last link closes the panel.
- On touch the first tap opens the panel and the second follows the link — which means the trigger itself must not navigate on the first tap. Below the tablet breakpoint, replace the whole thing with a drawer and accordions; a hover-driven panel has no touch equivalent worth building.
| Attribute | Applied to | Notes |
|---|---|---|
| aria-expanded | Each trigger | The state of the panel it controls. Without it a screen-reader user has no idea anything opened. |
| aria-haspopup | Triggers with a panel | Only on the ones that have one. On a plain link it announces something that is not there. |
| <nav aria-label="Main"> | The bar | A named landmark, so it can be skipped and distinguished from a footer nav. |
| <ul> / <li> | Each column | Real lists, so the screen reader announces "list, 3 items" and the user can decide whether to hear them. |
| Column heading | Each column | A real heading element, or the grouping exists only visually and the panel becomes one undifferentiated list of sixty links. |
Example usage
1import { MegaMenu, type MegaMenuGroup } from '@/ui/Navigation'23const groups: MegaMenuGroup[] = [4 {5 label: 'Products',6 columns: [7 {8 title: 'Compute', // headings are not decoration —9 items: [ // they are what makes it scannable10 { label: 'Containers', description: 'Run images anywhere', href: '/containers' },11 { label: 'Functions', description: 'Per-request billing', href: '/functions' },12 ],13 },14 { title: 'Data', items: [{ label: 'Postgres', href: '/postgres' }] },15 ],16 featured: <ReleaseCard post={latest} />,17 },18 // No columns → renders as a plain link, with no chevron19 { label: 'Pricing', columns: [] },20]2122<MegaMenu groups={groups} />2324// Defaults are the measured ones. Override only with a reason.25<MegaMenu groups={groups} openDelay={120} closeDelay={240} />2627// There is no touch equivalent — swap the component, not the timings28const isDesktop = useMediaQuery('(min-width: 1024px)')29{isDesktop ? <MegaMenu groups={groups} /> : <MobileNavDrawer groups={groups} />}Framework-free HTML
The markup is a button plus a list of lists. Everything else is timing.
<nav aria-label="Main" class="ds-mega">
<button
type="button"
class="ds-mega__trigger"
aria-expanded="true"
aria-haspopup="true"
>
Products
<svg aria-hidden="true"><!-- chevron --></svg>
</button>
<!-- Immediately after its trigger, so reading order matches the eye -->
<div class="ds-mega__panel">
<div class="ds-mega__column">
<h3 class="ds-mega__heading">Compute</h3>
<ul>
<li><a href="/containers">Containers <span>Run images anywhere</span></a></li>
<li><a href="/functions">Functions <span>Per-request billing</span></a></li>
</ul>
</div>
<div class="ds-mega__column">
<h3 class="ds-mega__heading">Data</h3>
<ul><li><a href="/postgres">Postgres</a></li></ul>
</div>
</div>
</nav>
<!-- No panel, so no chevron and no aria-haspopup -->
<a class="ds-mega__trigger" href="/pricing">Pricing</a>CSS
.ds-mega { position: relative; }
.ds-mega__panel {
position: absolute;
inset-inline: 0;
/* 6px: reads as attached, but clears the trigger's focus ring.
The pointer crosses this gap far faster than the close delay. */
inset-block-start: calc(100% + 6px);
z-index: 60;
display: flex;
flex-wrap: wrap;
gap: 24px 32px; /* column gutter wider than the row gap, so the
eye reads down a column, not across the panel */
padding: 20px;
border: 1px solid var(--ds-border);
border-radius: var(--radius-xl);
background: var(--ds-surface-overlay);
box-shadow: var(--shadow-e4);
animation: fade-in 140ms ease-out both;
}
.ds-mega__column { flex: 1; min-inline-size: 11rem; }
.ds-mega__heading {
font: var(--text-overline);
text-transform: uppercase;
color: var(--ds-fg-muted);
}
.ds-mega__trigger[aria-expanded='true'] {
background: var(--ds-layer-hover);
color: var(--ds-fg);
}
.ds-mega__trigger[aria-expanded='true'] svg { transform: rotate(180deg); }
/* No hover on touch, and no room for columns. Do not try to
make the same component work — swap it for a drawer. */
@media (max-width: 1023px) { .ds-mega { display: none; } }Component API
MegaMenu
| Prop | Type | Default | Description |
|---|---|---|---|
| groups* | MegaMenuGroup[] | — | The bar. A group with an empty columns array renders as a plain link with no chevron. |
| openDelay | number | 120 | Milliseconds the pointer must rest on a trigger before the panel opens. Ignored when a panel is already open. |
| closeDelay | number | 240 | Grace period after the pointer leaves. Entering the panel cancels it. |
| aria-label | string | 'Main' | Names the nav landmark. |
MegaMenuGroup
| Prop | Type | Default | Description |
|---|---|---|---|
| label* | string | — | The trigger text. |
| columns* | MegaMenuColumn[] | — | Two to four. Five is a wall. |
| featured | ReactNode | — | Promoted content, last in the row, on an inset surface. |
MegaMenuColumn
| Prop | Type | Default | Description |
|---|---|---|---|
| title* | string | — | The heading. Not optional — it is what makes the panel scannable. |
| items* | { label, description?, icon?, href? }[] | — | Descriptions are optional and should stay that way; one on every item doubles the panel height. |
Professional tips
- Order the columns by traffic, not by the org chart. The leftmost column is read first, and it should be the one most people came for.
- Cap it at about seven items per column. Longer than that and the panel starts to scroll, which is the signal the section wants its own landing page.
- Preload the destination on hover-intent. The 120ms delay is already a commitment signal, and it buys the fetch a head start for free.
- Close the panel on route change. A panel still hanging open over the page the user just navigated to is a bug people report as "the menu is stuck".
- If two sections have almost the same columns, that is a merge waiting to happen — the duplication is in the information architecture, not the menu.
Performance
- Render the panel only when open. Four panels of sixty links each is 240 anchors mounted on every page of the site.
- Animate opacity, not height. Height animation lays out the whole panel every frame, and on a menu that is the first thing a visitor touches.
- Do not fetch the menu contents. Navigation is not dynamic data; ship it in the document so it works before hydration.
- Set a fixed panel height per group if the contents are known, so opening does not reflow the page underneath.
Common mistakes
- Opening on contact, so panels flash at anyone whose pointer crosses the bar.
- Closing on pointer-out, so the diagonal path to the far column kills the panel halfway.
- Columns without headings, which is the version of a mega menu that Hick’s law really does describe.
- A chevron on every item, including the ones that are only a link.
- Sixty links left in the DOM and the tab order while the panel is closed.
- Trying to make the same component work on touch instead of swapping it for a drawer.
Real-world recommendations
- Measure the bounce rate from the panel itself. A panel that opens often and is clicked rarely is being opened by accident — raise the open delay before you redesign anything.
- The featured slot is where marketing will ask for a banner. Hold the line on one item: the moment it becomes two, the navigation moves below the fold on a laptop.
- A mega menu on an application is almost always a sidebar that lost an argument. Sites navigate by category; applications navigate by object, and those want different components.
- Keep the panel contents identical on every page. WCAG 3.2.3 asks for it, and it is also the only reason positional memory ever forms.
- If you find yourself adding a search box to the menu, the site has outgrown browsing. Put search in the bar, at full size, and let the menu handle the top of the funnel.