How to Create a Mega Menu for a Website With HTML and CSS (No Plugin Needed)

If your site has more than about 25 pages, a single-level dropdown starts to hurt. Links get buried, users scroll inside tiny menus, and your best content never gets clicked. A mega menu fixes that by showing an entire section of your site in one wide panel, organised into columns, with headings, icons and featured links.

This tutorial shows how to design a mega menu from the information architecture stage right through to production-ready HTML, CSS and a small JavaScript layer for keyboard and touch users. No page builder, no jQuery, no 200 KB plugin. Everything here works in every modern browser and degrades gracefully.

What a Mega Menu Is (and When You Actually Need One)

A mega menu is a large, rectangular dropdown panel that opens from a top-level navigation item and displays many links at once, grouped into visible columns and categories. Nielsen Norman Group research has consistently shown that these panels outperform traditional cascading dropdowns because everything is visible in one glance instead of hidden behind hover-and-pray sub-levels.

Use a mega menu when:

  • You have 4 or more top-level sections, each with 5 to 20 sub-pages.
  • Your categories benefit from visual grouping (product families, documentation topics, industries, resource types).
  • You want to promote specific pages such as pricing, a free trial, a flagship guide or a new release.
  • Analytics show users going straight to search because the nav does not surface what they need.

Skip it when you have fewer than 15 destination pages. A mega menu on a small site is just noise.

Mega Menu vs Standard Dropdown

Criteria Standard dropdown Mega menu
Links visible at once 5 to 10 15 to 40
Grouping and labels Flat list Column headings, icons, descriptions
Sub-levels needed Often 2 or 3 deep One level, no cascading
Promotion space None Featured card, CTA, image
Risk Hidden content Overload if unstructured
website navigation menu

Step 1: Design the Structure Before You Write Any Code

The biggest mistake in mega menu projects is opening a code editor first. Spend 30 minutes on paper or in a spreadsheet instead.

  1. Export your page list from your sitemap or CMS.
  2. Group pages into 3 to 5 columns per top-level item. Each column gets a short, noun-based heading such as “Hosting”, “Developer tools”, “By industry”. If a column needs more than 8 links, split it or move something to a landing page.
  3. Order links by demand, not alphabetically. Put your top traffic and top revenue pages first in each column.
  4. Reserve one slot for a featured block per panel: latest release, popular guide, pricing, demo booking.
  5. Write link labels as users would search them. “API documentation” beats “Resources for builders”.
  6. Cap the panel at roughly 30 links. Beyond that, add a “View all” link to a full section index page.

Visual rules that make a mega menu readable

  • Keep panel height under about 480 px so nothing gets cut off on a 13-inch laptop.
  • Use one accent colour for hover states, not five.
  • Icons should be 20 to 24 px, monochrome, and only used when they help identification. Decorative icons on every link slow scanning.
  • Give each link 8 to 12 px vertical padding so touch targets stay usable.
  • Left-align everything. Centered columns are much harder to scan.
  • Add a soft drop shadow and a light top border so the panel reads as a layer above the page.

Step 2: The HTML Skeleton

Semantic markup does most of the accessibility work for you. Use a <nav>, a real list, and a <button> for anything that opens a panel. Never use a bare <div> or an empty <a href="#"> as a trigger.

<nav class="mm" aria-label="Main navigation" data-mega>
  <a class="mm-logo" href="/">Acme</a>

  <button class="mm-burger" aria-expanded="false" aria-controls="mm-list">
    <span class="sr-only">Open menu</span>
    <span class="mm-burger-bars" aria-hidden="true"></span>
  </button>

  <ul class="mm-list" id="mm-list">

    <li class="mm-item mm-has-panel">
      <button class="mm-trigger" aria-expanded="false" aria-controls="panel-products">
        Products <span class="mm-caret" aria-hidden="true"></span>
      </button>

      <div class="mm-panel" id="panel-products">
        <div class="mm-grid">

          <div class="mm-col">
            <h2 class="mm-heading">Hosting</h2>
            <ul class="mm-links">
              <li><a href="/shared">
                <svg class="mm-ico" aria-hidden="true" viewBox="0 0 24 24"><use href="#i-server"></use></svg>
                <span>Shared hosting<span class="mm-desc">Best for small sites</span></span>
              </a></li>
              <li><a href="/vps"><span>VPS</span></a></li>
              <li><a href="/dedicated"><span>Dedicated servers</span></a></li>
            </ul>
          </div>

          <div class="mm-col">
            <h2 class="mm-heading">Developer tools</h2>
            <ul class="mm-links">
              <li><a href="/cli"><span>CLI</span></a></li>
              <li><a href="/api"><span>REST API</span></a></li>
              <li><a href="/webhooks"><span>Webhooks</span></a></li>
            </ul>
          </div>

          <div class="mm-col">
            <h2 class="mm-heading">By use case</h2>
            <ul class="mm-links">
              <li><a href="/ecommerce"><span>Ecommerce</span></a></li>
              <li><a href="/agencies"><span>Agencies</span></a></li>
              <li><a href="/saas"><span>SaaS teams</span></a></li>
            </ul>
          </div>

          <div class="mm-col mm-featured">
            <p class="mm-tag">New</p>
            <h2 class="mm-feat-title">Edge deploys in 12 seconds</h2>
            <p class="mm-desc">See the benchmark and migration guide.</p>
            <a class="mm-btn" href="/edge">Read the guide</a>
          </div>

        </div>
      </div>
    </li>

    <li class="mm-item"><a class="mm-link" href="/pricing">Pricing</a></li>
    <li class="mm-item"><a class="mm-link" href="/contact">Contact</a></li>
  </ul>
</nav>

Notes on the markup:

  • aria-expanded tells screen readers whether the panel is open. JavaScript flips it.
  • aria-controls links the trigger to the panel ID.
  • Column headings use real heading tags so the panel has an outline. If your page heading levels conflict, use <p class="mm-heading"> plus role="group" and aria-label on the column instead.
  • Icons carry aria-hidden="true" because the text already labels the link.
website navigation menu

Step 3: The CSS That Does the Heavy Lifting

The trick for a full-width panel is simple: the parent <nav> is position: relative, the list items stay position: static, and the panel is absolutely positioned with left: 0; right: 0. That stretches the panel edge to edge no matter where the trigger sits.

:root{
  --mm-ink:#16181d;
  --mm-muted:#6b7280;
  --mm-accent:#0b5fff;
  --mm-line:#e5e7eb;
  --mm-radius:10px;
}

.mm{
  position:relative;
  display:flex;
  align-items:center;
  gap:24px;
  padding:0 24px;
  background:#fff;
  border-bottom:1px solid var(--mm-line);
  font-family:system-ui,-apple-system,"Segoe UI",sans-serif;
}

.mm-list{display:flex;gap:2px;margin:0;padding:0;list-style:none}
.mm-item{position:static}

.mm-trigger,.mm-link{
  display:flex;align-items:center;gap:7px;
  padding:20px 14px;
  background:none;border:0;
  font:inherit;font-weight:600;font-size:.95rem;
  color:var(--mm-ink);text-decoration:none;cursor:pointer;
}
.mm-trigger:hover,.mm-link:hover,
.mm-trigger[aria-expanded="true"]{color:var(--mm-accent)}

.mm-caret{
  width:7px;height:7px;
  border-right:2px solid currentColor;
  border-bottom:2px solid currentColor;
  transform:rotate(45deg) translateY(-2px);
  transition:transform .2s ease;
}
.mm-trigger[aria-expanded="true"] .mm-caret{transform:rotate(225deg) translateY(2px)}

/* The panel */
.mm-panel{
  position:absolute;left:0;right:0;top:100%;
  z-index:60;
  padding:34px 24px 38px;
  background:#fff;
  border-top:1px solid var(--mm-line);
  box-shadow:0 26px 50px rgba(15,23,42,.12);
  opacity:0;visibility:hidden;
  transform:translateY(-10px);
  transition:opacity .18s ease,transform .18s ease,visibility .18s;
}

/* Three ways to open: mouse, keyboard, JS class */
.mm-has-panel:hover > .mm-panel,
.mm-has-panel:focus-within > .mm-panel,
.mm-has-panel.is-open > .mm-panel{
  opacity:1;visibility:visible;transform:translateY(0);
}

/* Columns */
.mm-grid{
  display:grid;
  grid-template-columns:repeat(4,minmax(0,1fr));
  gap:34px;
  max-width:1200px;margin:0 auto;
}
.mm-heading{
  margin:0 0 14px;
  font-size:.72rem;font-weight:700;
  letter-spacing:.09em;text-transform:uppercase;
  color:var(--mm-muted);
}
.mm-links{margin:0;padding:0;list-style:none}
.mm-links li+li{margin-top:4px}
.mm-links a{
  display:flex;gap:11px;align-items:flex-start;
  padding:8px 9px;border-radius:var(--mm-radius);
  color:var(--mm-ink);text-decoration:none;font-size:.93rem;
}
.mm-links a:hover,.mm-links a:focus-visible{background:#f2f6ff;color:var(--mm-accent)}
.mm-ico{flex:0 0 20px;width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.6}
.mm-desc{display:block;margin-top:2px;font-size:.8rem;font-weight:400;color:var(--mm-muted)}

/* Featured card */
.mm-featured{background:#f7f9fc;border:1px solid var(--mm-line);border-radius:14px;padding:22px}
.mm-tag{display:inline-block;margin:0 0 8px;padding:3px 9px;border-radius:99px;background:var(--mm-accent);color:#fff;font-size:.68rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase}
.mm-feat-title{margin:0 0 6px;font-size:1rem}
.mm-btn{display:inline-block;margin-top:14px;padding:9px 16px;border-radius:8px;background:var(--mm-accent);color:#fff;font-size:.87rem;font-weight:600;text-decoration:none}

/* Focus visibility, never remove it */
.mm :focus-visible{outline:3px solid #ffb703;outline-offset:2px}
.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}

@media (prefers-reduced-motion:reduce){
  .mm-panel,.mm-caret{transition:none}
}

Why :focus-within matters: that single line is what makes the menu usable with the Tab key even if JavaScript never loads. When a link inside the panel receives focus, the panel stays open. More at https://navbar.gallery.

Hover intent without JavaScript

Panels that flash open when the cursor grazes the nav feel broken. Add a short opening delay with a transition delay on the closed state:

.mm-panel{transition-delay:.12s}
.mm-has-panel:hover > .mm-panel,
.mm-has-panel.is-open > .mm-panel{transition-delay:0s}

Step 4: Mobile Collapse Behaviour

Hover does not exist on touch devices, so below your breakpoint the mega menu should become a vertical accordion inside a full-height drawer. Stack the grid to one column and let each top-level item expand in place.

@media (max-width:960px){
  .mm{flex-wrap:wrap;padding:0 16px}
  .mm-burger{display:block;margin-left:auto;background:none;border:0;padding:16px;cursor:pointer}

  .mm-list{
    display:none;
    flex-direction:column;
    width:100%;
    max-height:calc(100vh - 64px);
    overflow-y:auto;
    border-top:1px solid var(--mm-line);
    padding-bottom:24px;
  }
  .mm.is-nav-open .mm-list{display:flex}

  .mm-trigger,.mm-link{width:100%;justify-content:space-between;padding:16px 4px;border-bottom:1px solid var(--mm-line)}

  .mm-panel{
    position:static;
    opacity:1;visibility:visible;transform:none;
    display:none;
    padding:8px 0 18px;
    box-shadow:none;border-top:0;
  }
  .mm-has-panel.is-open > .mm-panel{display:block}
  /* hover must not open panels on touch */
  .mm-has-panel:hover > .mm-panel,
  .mm-has-panel:focus-within > .mm-panel{display:none}
  .mm-has-panel.is-open:focus-within > .mm-panel{display:block}

  .mm-grid{grid-template-columns:1fr;gap:20px}
  .mm-featured{margin-top:4px}
}

@media (min-width:961px){
  .mm-burger{display:none}
}

Recommended breakpoint behaviour

Viewport Layout Trigger
1280 px and up 4 columns, full-width panel Hover with 120 ms delay, plus click and keyboard
961 to 1279 px 3 columns, featured card wraps below Hover and click
Below 961 px Drawer, 1 column accordion Tap only, hover disabled

Step 5: The Small JavaScript Layer

CSS alone covers mouse and Tab users. JavaScript adds the things CSS cannot do: click and tap toggling, Escape to close, closing when focus or the pointer leaves, and correct aria-expanded values. Keep it under 60 lines.

(function () {
  const nav = document.querySelector('[data-mega]');
  if (!nav) return;

  const burger = nav.querySelector('.mm-burger');
  const triggers = Array.from(nav.querySelectorAll('.mm-trigger'));
  const isDesktop = () => window.matchMedia('(min-width: 961px)').matches;

  function closeAll(except) {
    triggers.forEach(function (t) {
      if (t === except) return;
      t.setAttribute('aria-expanded', 'false');
      t.closest('.mm-has-panel').classList.remove('is-open');
    });
  }

  triggers.forEach(function (trigger) {
    trigger.addEventListener('click', function () {
      const item = trigger.closest('.mm-has-panel');
      const open = trigger.getAttribute('aria-expanded') === 'true';
      closeAll(trigger);
      trigger.setAttribute('aria-expanded', String(!open));
      item.classList.toggle('is-open', !open);
    });
  });

  if (burger) {
    burger.addEventListener('click', function () {
      const open = burger.getAttribute('aria-expanded') === 'true';
      burger.setAttribute('aria-expanded', String(!open));
      nav.classList.toggle('is-nav-open', !open);
      if (open) closeAll();
    });
  }

  // Escape closes and returns focus to the trigger
  nav.addEventListener('keydown', function (e) {
    if (e.key !== 'Escape') return;
    const item = e.target.closest('.mm-has-panel.is-open');
    if (item) {
      const t = item.querySelector('.mm-trigger');
      t.setAttribute('aria-expanded', 'false');
      item.classList.remove('is-open');
      t.focus();
    } else if (nav.classList.contains('is-nav-open')) {
      nav.classList.remove('is-nav-open');
      burger.setAttribute('aria-expanded', 'false');
      burger.focus();
    }
  });

  // Click outside
  document.addEventListener('click', function (e) {
    if (!nav.contains(e.target)) closeAll();
  });

  // Focus leaving the nav on desktop
  nav.addEventListener('focusout', function (e) {
    if (isDesktop() && !nav.contains(e.relatedTarget)) closeAll();
  });
})();

Optional: arrow key navigation

Power users expect Left and Right arrows to move between top-level items. Add this inside the same function if your audience is developer-heavy:

nav.addEventListener('keydown', function (e) {
  if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
  const tops = Array.from(nav.querySelectorAll('.mm-trigger, .mm-link'));
  const i = tops.indexOf(document.activeElement);
  if (i === -1) return;
  e.preventDefault();
  const next = e.key === 'ArrowRight' ? i + 1 : i - 1;
  (tops[(next + tops.length) % tops.length]).focus();
});
website navigation menu

Accessibility Checklist

Requirement How this build satisfies it
Keyboard operable Native buttons and links, :focus-within, Escape handler
State announced aria-expanded updated on every toggle
Visible focus Explicit :focus-visible outline, never removed
No hover-only access Click and keyboard open the same panel
Touch targets 44 px 16 px vertical padding on mobile rows
Motion sensitivity prefers-reduced-motion disables transitions
Contrast Check muted text and hover background against 4.5:1

Performance and SEO Notes

  • Render the panels in the HTML, not with JavaScript on demand. Crawlers see the links immediately and there is no layout shift.
  • Never use display:none plus JS injection for content you want indexed heavily. Visibility toggling with visibility and opacity is safe and crawlable.
  • Inline SVG sprites beat icon fonts. One sprite block, zero extra requests, no FOUT.
  • Keep total nav links reasonable. Around 100 sitewide internal links in the header dilutes link equity and slows scanning. Prioritise instead of listing everything.
  • Budget: this component is roughly 3 KB of CSS and 1.5 KB of JS before compression. A typical mega menu plugin ships 80 to 250 KB plus jQuery.

Hand-coded vs plugin

Hand-coded (this tutorial) Builder or plugin
Page weight ~4.5 KB total 80 KB and up
Editing by non-developers Needs a template edit or CMS nav model Drag and drop
Accessibility control Full Depends on vendor
Update risk None Breaking changes, conflicts
website navigation menu

Testing Before You Ship

  1. Tab through the whole nav with the mouse untouched. Every panel must open and close cleanly.
  2. Disable JavaScript. Panels should still open on hover and on Tab focus.
  3. Test on a real phone, not just a resized browser window. Check the drawer scrolls and the address bar does not cut off the last item.
  4. Zoom the browser to 200 percent. Nothing should overlap or get clipped.
  5. Run a screen reader pass (NVDA on Windows, VoiceOver on macOS) and confirm the collapsed and expanded states are announced.
  6. Check contrast ratios on muted description text.
  7. Measure Cumulative Layout Shift. A correctly built mega menu adds zero CLS because the panel is absolutely positioned.

Common Mega Menu Mistakes

  • Making the top-level item a non-clickable button when a section landing page exists. Solution: split it into a link plus a small caret button, or add a “View all X” link as the first item in the panel.
  • Closing the panel the instant the cursor leaves the trigger. Because the panel is a child of the list item in this markup, the hover state persists naturally. Do not move the panel outside the item.
  • Panels taller than the viewport. Cap the number of links or make the panel scrollable with max-height.
  • Icons for everything. Icons should differentiate, not decorate.
  • Different column counts on every panel. Keep the grid consistent so users build a mental model.
  • Animating height. Animate opacity and transform only. Height animation causes jank and layout shift.

FAQ

What is a mega menu?

A mega menu is a wide dropdown panel in a site header that reveals many navigation links at once, organised into labelled columns and often enriched with icons, short descriptions and a featured promotional block. Unlike cascading dropdowns, it shows one level of depth in a single visible layer.

What does a mega menu look like?

Picture a rectangle that spans most or all of the header width, opening below the nav bar. Inside it you will typically see 3 to 5 columns, each with an uppercase category heading and 4 to 8 links, plus a highlighted card on the right side for a new product, guide or call to action. See https://elementpack.pro.

How many links should a mega menu contain?

Between 15 and 30 links per panel works best. Fewer and a normal dropdown is enough. More and users start scanning instead of reading, which defeats the purpose. Add a “View all” link when a section genuinely has more pages.

Do I need JavaScript to build a mega menu?

No, not for the core behaviour. CSS :hover and :focus-within handle opening and closing for mouse and keyboard users. JavaScript is only needed for tap toggling on touch devices, Escape to close, and keeping aria-expanded accurate for screen readers. That is about 50 lines of code.

Are mega menus bad for SEO?

Not when the links are present in the server-rendered HTML. Crawlers follow them normally. The only real risk is putting hundreds of links in every header, which spreads internal link value thin and makes it harder for search engines to identify your priority pages. Curate rather than dump your sitemap.

How should a mega menu behave on mobile?

Collapse it into a full-height drawer opened by a hamburger button, where each top-level item becomes an accordion. Disable hover, use tap only, keep touch targets at least 44 px tall, and make the drawer scrollable with the panel content stacked into one column.

Can I use this code in WordPress, Shopify or a static site?

Yes. The markup is framework agnostic. In WordPress you can output it from a custom nav walker or hard-code it in header.php. In Shopify it maps cleanly onto linklists. On a static or React site, the same classes and ARIA attributes apply, with the is-open class driven by state instead of a class toggle.

Wrapping Up

Knowing how to design a mega menu is 20 percent code and 80 percent editorial discipline. Group your pages into scannable columns, name them the way users search, promote a handful of high-value destinations, then implement with semantic HTML, a CSS grid inside an absolutely positioned panel, and a thin JavaScript layer for touch and keyboard. The result loads in a few kilobytes, passes accessibility review, and you never have to worry about a plugin update breaking your header. For the wider picture, see Studio Editor: Adding and Customizing a Mega Menu.

Copy the snippets above into your project, swap the columns for your own structure, and test with the checklist before you ship.

Recent Posts

No Posts Found!

Categories

Tags

    Subscribe

    You have been successfully Subscribed! Ops! Something went wrong, please try again.

    About Us

    Express Jam Studio was founded in 2004 by John Smith. John had previously worked for a courier company, but he saw an opportunity to start his own business in the web design and development industry.

    Contact Info

    Copyright © 2022 Express Jam Studio. All Rights Reserved.