Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

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.

Northwind

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.

ProductsSolutionsDevelopers
Close on exit → panel dies mid-path

Closing the instant the pointer leaves the trigger punishes the most natural path there is — a straight line to the target.

ProductsSolutionsDevelopers
Grace period → panel survives

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.

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.

has childrenChevron, panel
one pagePlain link, no chevron

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.

Menu
Products−
ContainersFunctionsEdge
Solutions+
Developers+

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.

Closed
Open
Openingafter 120ms
Products ▾
Trigger hover
Products ▾
Trigger focus
Products ▴
Expandedchevron rotated
Pricing
No children
no hover path
Touchtap to open

Anatomy

Every part, every measurement, and the reason it is that number.

Northwind
Compute
  • ContainersRun images anywhere
  • FunctionsPer-request billing
  • Edge38 regions
Data
  • PostgresManaged, with branching
  • Object storageS3-compatible
  • AnalyticsColumnar, sub-second
Platform
  • IdentitySSO and SCIM
  • ObservabilityTraces and logs
NewPostgres branching

Fork a production database in about two seconds and throw it away when the pull request merges.

“Products” open. Four triggers, three labelled columns, and one featured slot — everything below the bar is a single panel, not four stacked menus.

  1. 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.

  2. Chevron13px, rotates 180°

    Present only on triggers that actually have a panel. A chevron on a plain link teaches users the affordance means nothing.

  3. 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.

  4. Close delay240ms

    The grace period that covers the diagonal from the trigger to the far column. Entering the panel cancels it outright.

  5. 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.

  6. 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.

  7. 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.

  8. Item description12px, one line

    Optional, and it should stay optional. Descriptions on every item double the panel height and stop it being scannable.

  9. 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.

  10. 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.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
paddingPanel inset
column gapBetween columns

Radius

TokenValueUsed for
--radius-xl—Panel corners

Shadow

TokenValueUsed for
--shadow-e4—Panel elevation

Typography

TokenValueUsed for
--text-overlineColumn headings

Motion

TokenValueUsed for
openDelayHover intent in
closeDelayGrace period out

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusLabel gapMin widthMax widthWhen to use
Trigger36px0 12px—4px——Label plus chevron. Sits inside a 56–64px bar.
Panel—20px--radius-xl——the content containerFull container width. Never wider than the bar it hangs from.
Column———32px11rem—Two to four columns. Five is a wall.
Item32px6px 8px————Label only.
Item with description48px6px 8px————Two lines. Use sparingly.
Featured—16px——13rem—One promoted item, last in the row.
Panel heightmax 70vh—————Past this the panel needs a scrollbar, which is the point at which it has too much in it.

Do

ComputeContainersFunctions
Label every columnThe headings are what make a large panel scannable. Three headings turn sixty links into three decisions and then a dozen; without them it is genuinely a wall.
click · Enter · Space · hover
Open on click as well as hoverTouch has no hover and a keyboard has no pointer. Hover is the accelerator for people with a mouse; the button underneath is the actual mechanism.
in 120ms · out 240ms · switch 0ms
Keep the grace period asymmetricOpening is a commitment and should cost a moment; closing is recoverable and should be forgiving. Equal delays make one of the two feel wrong.
slide along the bar
Switch panels instantly once one is openThe user has already proven intent by opening the first panel. Charging the open delay again for every sibling makes the whole bar feel sticky.
Products ▾Pricing
Give the chevron to items that have a panelIt is the only signal that a trigger holds more. Putting it on every item, including the ones that are just a link, makes it meaningless.

Don't

openDelay: 0 → panels flash at every pass
Do not open on contactA pointer travelling to the search box crosses the bar and detonates a panel over the page. It also makes the bar unusable for anyone who moves the mouse while reading.
ProductsSolutionsDevelopers
Close on exit → panel dies mid-path
Do not close the moment the pointer leavesThe straight line from the trigger to the far column leaves the trigger almost immediately. Closing there punishes the most natural gesture a user can make.
Do not nest a menu inside the panelA flyout off a mega menu compounds the diagonal problem in two directions at once and gives the keyboard a level it cannot reason about. If the section needs a third level, it needs a page.
email field inside a panel that closes on pointer-out
Do not put a form in itA panel that vanishes on a 240ms timer is not a place to type. Search, sign-in and newsletter fields belong in the bar itself or on a page.
Do not exceed about five columnsPast five the panel spans the full width of a desktop monitor and the eye has nowhere to start. That is the point where the grouping stops rescuing it.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.13Content on Hover or FocusAA2.1.1KeyboardA2.4.5Multiple WaysAA3.2.3Consistent NavigationAA4.1.2Name, Role, ValueA

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

TabMoves through the triggers, then into the open panel’s links.
Enter / SpaceOpens or closes the panel for the focused trigger.
↓Opens the panel and moves into it.
← →Moves between triggers along the bar.
EscapeCloses the panel and returns focus to its trigger.
Tab past the last linkCloses 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.
AttributeApplied toNotes
aria-expandedEach triggerThe state of the panel it controls. Without it a screen-reader user has no idea anything opened.
aria-haspopupTriggers with a panelOnly on the ones that have one. On a plain link it announces something that is not there.
<nav aria-label="Main">The barA named landmark, so it can be skipped and distinguished from a footer nav.
<ul> / <li>Each columnReal lists, so the screen reader announces "list, 3 items" and the user can decide whether to hear them.
Column headingEach columnA real heading element, or the grouping exists only visually and the panel becomes one undifferentiated list of sixty links.

Code

Example usage

tsx
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.

html
<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

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

PropTypeDefaultDescription
groups*MegaMenuGroup[]—The bar. A group with an empty columns array renders as a plain link with no chevron.
openDelaynumber120Milliseconds the pointer must rest on a trigger before the panel opens. Ignored when a panel is already open.
closeDelaynumber240Grace period after the pointer leaves. Entering the panel cancels it.
aria-labelstring'Main'Names the nav landmark.

MegaMenuGroup

PropTypeDefaultDescription
label*string—The trigger text.
columns*MegaMenuColumn[]—Two to four. Five is a wall.
featuredReactNode—Promoted content, last in the row, on an inset surface.

MegaMenuColumn

PropTypeDefaultDescription
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.

Notes

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.