Summit Themes
Blog

How to build a full-width mega menu with Tailwind CSS and Alpine.js

A mega menu is the kind of navigation component that sounds harder than it is. The reputation comes from years of jQuery-era code full of position calculations, timeouts to handle hover gaps, and hacks to stop the menu from flickering when the cursor crossed a gap. With Tailwind CSS v4 and Alpine.js v3 you can build a polished, accessible, full-width version in one self-contained HTML block — no custom JavaScript file, no CSS you'll regret later.

This guide walks through the whole thing from scratch: the markup structure, the Alpine.js reactive state, the Tailwind layout tricks that make it truly full-width, and the keyboard accessibility that makes it usable without a mouse. The final result is a nav bar with two mega menu triggers, a simple link, and a CTA button — the kind of header that works for any services or SaaS site.

What you need

The only hard requirements are Tailwind CSS v4 and Alpine.js v3 loaded on the page. If you are using Astro (or any other framework), install them the usual way. If you just want a standalone HTML file to experiment with, load both from CDN:

<!-- Alpine.js v3 -->
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

<!-- Tailwind CSS v4 via CDN (dev-only; use the CLI in production) -->
<script src="https://cdn.tailwindcss.com"></script>

In a real project with Tailwind v4, your CSS entry point just needs @import "tailwindcss";. If you have custom tokens, define them with @theme:

@import "tailwindcss";

@theme {
  --color-brand: #0f6fff;
  --color-brand-dark: #0050cc;
  --font-sans: "Inter", sans-serif;
}

That replaces the old tailwind.config.js — every token you define maps directly to utility classes.

How the full-width trick works

A "full-width" mega menu means the dropdown panel stretches edge-to-edge across the viewport even though the trigger button sits somewhere in the middle of the nav. The cleanest approach is to position the panel relative to the <nav> element itself, not the trigger button. That means the nav needs relative positioning, and the panel gets absolute left-0 right-0 to fill it. If the nav spans the full viewport width (which it should), the panel does too.

No JavaScript position calculations needed. No getBoundingClientRect(). Just CSS.

The Alpine.js state model

Each mega menu section needs its own open/closed boolean. Alpine's x-data directive sets that up directly on the nav element, so all triggers share the same reactive scope:

<nav x-data="{ activeMenu: null }">
  ...
</nav>

Instead of a separate isOpen boolean per menu, a single activeMenu string (or null) tracks which menu is open. A trigger sets activeMenu = 'solutions'; the panel checks activeMenu === 'solutions'. This means only one menu is ever open at a time, which is the right UX.

Building the nav bar

Here is the complete markup. Read through it once, then the explanations below break down the key parts:

<nav
  x-data="{ activeMenu: null }"
  @keydown.escape.window="activeMenu = null"
  class="relative bg-white border-b border-gray-200"
>
  <div class="max-w-7xl mx-auto px-6 flex items-center justify-between h-16">

    <!-- Logo -->
    <a href="/" class="font-bold text-xl text-gray-900">Acme Co</a>

    <!-- Nav links -->
    <ul class="hidden lg:flex items-center gap-1">

      <!-- Mega menu trigger: Solutions -->
      <li class="relative">
        <button
          @click="activeMenu = activeMenu === 'solutions' ? null : 'solutions'"
          @click.outside="activeMenu = null"
          :aria-expanded="activeMenu === 'solutions'"
          aria-haspopup="true"
          class="flex items-center gap-1 px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 rounded-md hover:bg-gray-50 transition-colors"
        >
          Solutions
          <svg
            :class="activeMenu === 'solutions' ? 'rotate-180' : ''"
            class="w-4 h-4 transition-transform"
            fill="none" stroke="currentColor" viewBox="0 0 24 24"
          >
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
          </svg>
        </button>
      </li>

      <!-- Mega menu trigger: Resources -->
      <li class="relative">
        <button
          @click="activeMenu = activeMenu === 'resources' ? null : 'resources'"
          @click.outside="activeMenu = null"
          :aria-expanded="activeMenu === 'resources'"
          aria-haspopup="true"
          class="flex items-center gap-1 px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 rounded-md hover:bg-gray-50 transition-colors"
        >
          Resources
          <svg
            :class="activeMenu === 'resources' ? 'rotate-180' : ''"
            class="w-4 h-4 transition-transform"
            fill="none" stroke="currentColor" viewBox="0 0 24 24"
          >
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
          </svg>
        </button>
      </li>

      <li>
        <a href="/pricing" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 rounded-md hover:bg-gray-50 transition-colors">Pricing</a>
      </li>

    </ul>

    <!-- CTA -->
    <a
      href="/get-started"
      class="hidden lg:inline-flex items-center px-4 py-2 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
    >
      Get started
    </a>

  </div>

  <!-- ============================================================
       MEGA MENU PANELS — positioned relative to <nav>, not <li>
       ============================================================ -->

  <!-- Solutions panel -->
  <div
    x-show="activeMenu === 'solutions'"
    x-transition:enter="transition ease-out duration-200"
    x-transition:enter-start="opacity-0 -translate-y-1"
    x-transition:enter-end="opacity-100 translate-y-0"
    x-transition:leave="transition ease-in duration-150"
    x-transition:leave-start="opacity-100 translate-y-0"
    x-transition:leave-end="opacity-0 -translate-y-1"
    class="absolute left-0 right-0 top-full z-50 bg-white border-b border-gray-200 shadow-lg"
  >
    <div class="max-w-7xl mx-auto px-6 py-8 grid grid-cols-3 gap-8">

      <div>
        <p class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-3">By role</p>
        <ul class="space-y-2">
          <li><a href="/solutions/developers" class="flex items-start gap-3 group">
            <span class="mt-0.5 text-blue-600">&#x2699;</span>
            <span>
              <strong class="block text-sm font-medium text-gray-900 group-hover:text-blue-600">Developers</strong>
              <span class="text-xs text-gray-500">APIs, SDKs, and tooling</span>
            </span>
          </a></li>
          <li><a href="/solutions/marketers" class="flex items-start gap-3 group">
            <span class="mt-0.5 text-blue-600">&#x1F4CA;</span>
            <span>
              <strong class="block text-sm font-medium text-gray-900 group-hover:text-blue-600">Marketers</strong>
              <span class="text-xs text-gray-500">Analytics and campaigns</span>
            </span>
          </a></li>
          <li><a href="/solutions/designers" class="flex items-start gap-3 group">
            <span class="mt-0.5 text-blue-600">&#x1F3A8;</span>
            <span>
              <strong class="block text-sm font-medium text-gray-900 group-hover:text-blue-600">Designers</strong>
              <span class="text-xs text-gray-500">Tokens, themes, Figma kit</span>
            </span>
          </a></li>
        </ul>
      </div>

      <div>
        <p class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-3">By industry</p>
        <ul class="space-y-2">
          <li><a href="/solutions/agency" class="block text-sm font-medium text-gray-700 hover:text-blue-600">Agencies</a></li>
          <li><a href="/solutions/saas" class="block text-sm font-medium text-gray-700 hover:text-blue-600">SaaS</a></li>
          <li><a href="/solutions/ecommerce" class="block text-sm font-medium text-gray-700 hover:text-blue-600">E-commerce</a></li>
          <li><a href="/solutions/enterprise" class="block text-sm font-medium text-gray-700 hover:text-blue-600">Enterprise</a></li>
        </ul>
      </div>

      <div class="bg-gray-50 rounded-xl p-6">
        <p class="text-sm font-semibold text-gray-900 mb-1">New: v4 theming guide</p>
        <p class="text-xs text-gray-500 mb-4">Migrate your design tokens to the new CSS-first @theme system in under an hour.</p>
        <a href="/blog/v4-theming" class="text-sm font-semibold text-blue-600 hover:underline">Read the guide &rarr;</a>
      </div>

    </div>
  </div>

  <!-- Resources panel -->
  <div
    x-show="activeMenu === 'resources'"
    x-transition:enter="transition ease-out duration-200"
    x-transition:enter-start="opacity-0 -translate-y-1"
    x-transition:enter-end="opacity-100 translate-y-0"
    x-transition:leave="transition ease-in duration-150"
    x-transition:leave-start="opacity-100 translate-y-0"
    x-transition:leave-end="opacity-0 -translate-y-1"
    class="absolute left-0 right-0 top-full z-50 bg-white border-b border-gray-200 shadow-lg"
  >
    <div class="max-w-7xl mx-auto px-6 py-8 grid grid-cols-2 gap-8">

      <div>
        <p class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-3">Learn</p>
        <ul class="space-y-2">
          <li><a href="/docs" class="block text-sm font-medium text-gray-700 hover:text-blue-600">Documentation</a></li>
          <li><a href="/blog" class="block text-sm font-medium text-gray-700 hover:text-blue-600">Blog</a></li>
          <li><a href="/tutorials" class="block text-sm font-medium text-gray-700 hover:text-blue-600">Tutorials</a></li>
          <li><a href="/changelog" class="block text-sm font-medium text-gray-700 hover:text-blue-600">Changelog</a></li>
        </ul>
      </div>

      <div>
        <p class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-3">Community</p>
        <ul class="space-y-2">
          <li><a href="/discord" class="block text-sm font-medium text-gray-700 hover:text-blue-600">Discord</a></li>
          <li><a href="/github" class="block text-sm font-medium text-gray-700 hover:text-blue-600">GitHub</a></li>
          <li><a href="/support" class="block text-sm font-medium text-gray-700 hover:text-blue-600">Support</a></li>
        </ul>
      </div>

    </div>
  </div>

  <!-- Backdrop (closes menu on outside click, adds visual scrim) -->
  <div
    x-show="activeMenu !== null"
    x-transition:enter="transition ease-out duration-200"
    x-transition:enter-start="opacity-0"
    x-transition:enter-end="opacity-100"
    x-transition:leave="transition ease-in duration-150"
    x-transition:leave-start="opacity-100"
    x-transition:leave-end="opacity-0"
    @click="activeMenu = null"
    class="fixed inset-0 z-40 bg-gray-900/20"
    aria-hidden="true"
  ></div>

</nav>

Breaking down the key decisions

Single activeMenu string instead of multiple booleans

Using activeMenu: null instead of solutionsOpen: false, resourcesOpen: false gives you mutual exclusivity for free. When you click "Resources" while "Solutions" is open, setting activeMenu = 'resources' automatically closes the first panel. You never need to manually close the other one.

The toggle logic on each button is activeMenu = activeMenu === 'solutions' ? null : 'solutions'. Clicking the same button again closes it. Clicking a different button switches to that panel.

Keyboard accessibility

Two things handle keyboard dismissal. First, @keydown.escape.window="activeMenu = null" on the nav catches Escape presses from anywhere in the document (the .window modifier listens on the window object rather than just the element). Second, @click.outside="activeMenu = null" on each trigger button closes the panel when the user clicks anywhere outside that button. The .outside modifier only fires when the element it is registered on is visible, so there are no spurious close events.

The :aria-expanded binding on each trigger button reflects the current state to screen readers. Combined with aria-haspopup="true", screen readers know the button controls a popup region and can announce whether it is currently open.

The backdrop element

The fixed backdrop serves two purposes: it gives the page a subtle visual scrim so users understand they are in a focused navigation mode, and it is the primary outside-click target. Because it sits at z-40 while the panels sit at z-50, the panels always render on top of it. Clicking the backdrop runs activeMenu = null, which closes everything.

Transitions

Alpine's x-transition hooks run CSS class transitions on enter and leave. The pattern here fades the panel in while sliding it down very slightly (-translate-y-1 to translate-y-0), which gives a sense of the panel opening downward without being distracting. You can swap these classes for any transition you want — the API just applies the classes at the right lifecycle moments.

Why top-full instead of a fixed top value

The panels use top-full, which in Tailwind v4 maps to top: 100%. Since the panels are positioned absolutely within the nav, top: 100% means "immediately below the nav bar". If you change the nav height, the panels follow automatically. No magic pixel values to maintain.

Adding hover support

Click-to-open is the accessible default. Some designers want hover-to-open on desktop. You can layer that on without breaking the click behavior by adding @mouseenter and @mouseleave to the list items that wrap each trigger:

<li
  @mouseenter="activeMenu = 'solutions'"
  @mouseleave="activeMenu = null"
>
  <button ...>Solutions</button>
</li>

One thing to watch: @mouseleave on the <li> fires when the cursor moves from the button into the panel — unless the panel is inside the same <li>. In the markup above, the panels live directly inside <nav>, outside the list items. That means moving the cursor into the panel will fire mouseleave on the <li> and close the menu.

The cleanest fix is to wrap the trigger and its panel together in the same container, or to move panels inside each <li> and accept that they will not be full-width. If you need true full-width panels and hover support, use a small delay with a CSS custom property or a debounced Alpine effect. For most sites, click-to-open is the right default — it avoids accidental triggers and works identically on touch devices.

Mobile nav

The mega menu as written is hidden below lg breakpoint with hidden lg:flex on the <ul>. Your mobile nav (a full-screen drawer or a simple stacked accordion) is a separate concern. A common pattern is a hamburger button that toggles a second Alpine component — a drawer with the same link structure, but rendered as a vertical accordion rather than a horizontal flyout.

Keeping them separate is intentional. The mega menu's layout assumptions (grid columns, absolute positioning, viewport width) do not translate to a 375px screen. Build the mobile nav as its own component and share only the link data between them.

A note on Tailwind v4 utilities used here

Everything in the markup above is standard Tailwind — no custom classes, no arbitrary values for the layout. top-full, left-0, right-0, z-50, grid-cols-3, and gap-8 are all built-in. In Tailwind v4 these still work exactly as they did in v3; the main change is that you define custom tokens in CSS with @theme rather than in a JavaScript config file. If you are migrating from v3, the utilities themselves are largely unchanged — it is the configuration layer that is different.

If you need a custom brand color on the active state, define it in your CSS entry point and use the generated utility class just like any other Tailwind class:

@theme {
  --color-brand: #0f6fff;
}

/* Now use bg-brand, text-brand, border-brand in your markup */

What to do next

The code above is a working foundation. The most common additions from here are: a mobile drawer component, active-link highlighting (using :class bindings against the current URL), and a sticky nav that adds a shadow on scroll (a one-liner with Alpine's @scroll.window and a :class binding). Each of those layers on top without touching what is already there.

The pattern worth remembering is the separation of concerns: Tailwind handles every visual detail, Alpine handles every interactive state, and the HTML structure is the glue between them. Once you understand that split, almost any navigation pattern — tabs, accordion, drawer, command palette — follows the same mental model.