Vue componentFree

Mega Menu Navbar

Vue 3 navbar with Products and Resources mega-menu panels, keyboard navigation and a mobile accordion. Built with Tailwind CSS, dark mode ready.

by Vueplayv1.0.0Updated 3 hours ago

Install
Full screen ↗

About this component

A header for products with more to show than five links: top-level items open wide panels with grouped columns of links, each with an icon, title and one-line description, plus an optional highlighted card for a launch or case study.

Panels are disclosure buttons with aria-expanded and aria-controls. ArrowLeft and ArrowRight move between top-level items, ArrowDown jumps into the open panel, ArrowUp and ArrowDown cycle its links, and Escape closes it and returns focus to its trigger. Clicking outside or tabbing away also closes it.

Below 1024px everything collapses behind a hamburger into an accordion, so the same data drives both layouts. Items without columns render as plain links. The icon set is a small map of inline SVG paths you can extend.

  • Multi-column panels with icons and descriptions
  • Optional featured card per panel
  • Arrow key and Escape keyboard support
  • Accordion menu on mobile
  • Light and dark mode

Code

11 files in the repo
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId } from 'vue'

export type MegaIcon = 'chart' | 'bolt' | 'shield' | 'cube' | 'book' | 'chat' | 'code' | 'users' | 'calendar' | 'sparkles'

export interface MegaLink {
  title: string
  description: string
  href: string
  icon?: MegaIcon
}

export interface MegaColumn {
  heading: string
  links: MegaLink[]
}

export interface MegaFeature {
  eyebrow: string
  title: string
  description: string
  href: string
  cta: string
}

export interface MegaNavItem {
  label: string
  /** Plain link. Ignored when `columns` is set. */
  href?: string
  /** Columns shown in the mega-menu panel. */
  columns?: MegaColumn[]
  /** Optional highlighted card on the right side of the panel. */
  feature?: MegaFeature
}

const props = withDefaults(defineProps<{
  brand?: string
  items?: MegaNavItem[]
  ctaLabel?: string
  ctaHref?: string
  loginLabel?: string
  loginHref?: string
  /** Label of a menu to open on first render (useful for demos and docs). */
  defaultOpen?: string
}>(), {
  brand: 'Northwind',
  ctaLabel: 'Get a demo',
  ctaHref: '#demo',
  loginLabel: 'Sign in',
  loginHref: '#signin',
  defaultOpen: '',
  items: () => [
    {
      label: 'Products',
      columns: [
        {
          heading: 'Platform',
          links: [
            { title: 'Insights', description: 'Dashboards that answer questions before you ask them.', href: '#insights', icon: 'chart' },
            { title: 'Automations', description: 'Trigger workflows from any event in your stack.', href: '#automations', icon: 'bolt' },
            { title: 'Data Vault', description: 'Encrypted storage with fine-grained access rules.', href: '#vault', icon: 'shield' },
          ],
        },
        {
          heading: 'Build',
          links: [
            { title: 'API', description: 'Typed REST and webhooks with generous rate limits.', href: '#api', icon: 'code' },
            { title: 'Integrations', description: 'Connect 120+ tools without writing glue code.', href: '#integrations', icon: 'cube' },
            { title: 'AI Assist', description: 'Draft queries and summaries in plain language.', href: '#assist', icon: 'sparkles' },
          ],
        },
      ],
      feature: {
        eyebrow: 'New in 4.0',
        title: 'Realtime pipelines',
        description: 'Stream events into dashboards with sub-second latency. No batch jobs to babysit.',
        href: '#realtime',
        cta: 'See what changed',
      },
    },
    {
      label: 'Resources',
      columns: [
        {
          heading: 'Learn',
          links: [
            { title: 'Documentation', description: 'Guides, references and runnable examples.', href: '#docs', icon: 'book' },
            { title: 'Webinars', description: 'Monthly live sessions with our product team.', href: '#webinars', icon: 'calendar' },
          ],
        },
        {
          heading: 'Connect',
          links: [
            { title: 'Community', description: 'Ask questions and share what you have built.', href: '#community', icon: 'users' },
            { title: 'Support', description: 'Talk to a human, usually within the hour.', href: '#support', icon: 'chat' },
          ],
        },
      ],
    },
    { label: 'Pricing', href: '#pricing' },
    { label: 'Customers', href: '#customers' },
  ],
})

const emit = defineEmits<{
  navigate: [href: string, label: string]
}>()

const icons: Record<MegaIcon, string> = {
  chart: 'M4 20V10m6 10V4m6 16v-7m4 7H3',
  bolt: 'M13 3 5 13.5h6L10 21l8-10.5h-6L13 3Z',
  shield: 'M12 3 5 6v5.5c0 4.2 2.9 7.9 7 9 4.1-1.1 7-4.8 7-9V6l-7-3Zm-3 9 2 2 4-4',
  cube: 'm12 3 8 4.5v9L12 21l-8-4.5v-9L12 3Zm0 9 8-4.5M12 12v9m0-9L4 7.5',
  book: 'M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5m0-15v15m0 0A1.5 1.5 0 0 0 6.5 21H19M9 7h6',
  chat: 'M4 5h16v11H9l-5 4V5Zm4 5h8M8 13h5',
  code: 'm8 8-4 4 4 4m8-8 4 4-4 4M14 5l-4 14',
  users: 'M16 19v-1a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v1m6.5-9a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM21 19v-1a4 4 0 0 0-3-3.9M15.5 3.1a3.5 3.5 0 0 1 0 6.8',
  calendar: 'M4 6h16v14H4V6Zm0 4h16M8 3v4m8-4v4',
  sparkles: 'M10 4l1.6 4.4L16 10l-4.4 1.6L10 16l-1.6-4.4L4 10l4.4-1.6L10 4Zm8 10 .8 2.2 2.2.8-2.2.8L18 20l-.8-2.2L15 17l2.2-.8L18 14Z',
}

const uid = useId()
const panelId = (i: number) => `${uid}-panel-${i}`
const mobileId = `${uid}-mobile`

const openIndex = ref<number | null>(null)
const mobileOpen = ref(false)
const mobileSection = ref<number | null>(null)
const navRef = ref<HTMLElement | null>(null)
const triggerRefs = ref<HTMLElement[]>([])
const panelRefs = ref<HTMLElement[]>([])
const burgerRef = ref<HTMLButtonElement | null>(null)

const initial = props.items.findIndex(i => i.label === props.defaultOpen && i.columns)
if (initial >= 0) openIndex.value = initial

const hasPanel = (item: MegaNavItem) => !!item.columns?.length
const topLevel = computed(() => props.items.map((item, index) => ({ item, index })))

function toggle(i: number) {
  openIndex.value = openIndex.value === i ? null : i
}

async function openAndFocusFirst(i: number) {
  openIndex.value = i
  await nextTick()
  panelRefs.value.find(p => p.dataset.index === String(i))?.querySelector<HTMLElement>('a')?.focus()
}

function focusTrigger(i: number) {
  const count = props.items.length
  const next = ((i % count) + count) % count
  triggerRefs.value.find(t => t.dataset.index === String(next))?.focus()
}

function onTriggerKeydown(e: KeyboardEvent, i: number) {
  if (e.key === 'ArrowDown' && hasPanel(props.items[i]!)) {
    e.preventDefault()
    void openAndFocusFirst(i)
  } else if (e.key === 'ArrowRight') {
    e.preventDefault()
    focusTrigger(i + 1)
  } else if (e.key === 'ArrowLeft') {
    e.preventDefault()
    focusTrigger(i - 1)
  }
}

function onPanelKeydown(e: KeyboardEvent) {
  const panel = e.currentTarget as HTMLElement
  const links = Array.from(panel.querySelectorAll<HTMLElement>('a'))
  const at = links.indexOf(document.activeElement as HTMLElement)
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
    e.preventDefault()
    const step = e.key === 'ArrowDown' ? 1 : -1
    links[(at + step + links.length) % links.length]?.focus()
  }
}

function closeAll(returnFocus = false) {
  const was = openIndex.value
  openIndex.value = null
  if (returnFocus && was !== null) focusTrigger(was)
}

function onFocusOut(e: FocusEvent) {
  // Only react to focus moving somewhere else on the page (clicks outside are handled by pointerdown).
  const next = e.relatedTarget as Node | null
  if (!next || navRef.value?.contains(next)) return
  openIndex.value = null
}

function select(href: string, label: string) {
  emit('navigate', href, label)
  openIndex.value = null
  mobileOpen.value = false
}

function onKeydown(e: KeyboardEvent) {
  if (e.key !== 'Escape') return
  if (openIndex.value !== null) closeAll(true)
  else if (mobileOpen.value) {
    mobileOpen.value = false
    burgerRef.value?.focus()
  }
}

function onPointerDown(e: PointerEvent) {
  if (!navRef.value?.contains(e.target as Node)) {
    openIndex.value = null
    mobileOpen.value = false
  }
}

let mq: MediaQueryList | undefined
const onBreakpoint = (e: MediaQueryListEvent) => { if (e.matches) mobileOpen.value = false }

onMounted(() => {
  document.addEventListener('keydown', onKeydown)
  document.addEventListener('pointerdown', onPointerDown)
  mq = window.matchMedia('(min-width: 1024px)')
  mq.addEventListener('change', onBreakpoint)
})
onBeforeUnmount(() => {
  document.removeEventListener('keydown', onKeydown)
  document.removeEventListener('pointerdown', onPointerDown)
  mq?.removeEventListener('change', onBreakpoint)
})

const focusRing = 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600 dark:focus-visible:outline-sky-400'
</script>

<template>
  <header ref="navRef" class="relative z-40 border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-950" @focusout="onFocusOut">
    <nav aria-label="Main" class="mx-auto flex h-16 max-w-7xl items-center gap-8 px-4 sm:px-6 lg:px-8">
      <a href="#" class="flex shrink-0 items-center gap-2 rounded-md" :class="focusRing">
        <slot name="logo">
          <svg viewBox="0 0 32 32" aria-hidden="true" class="size-8">
            <rect width="32" height="32" rx="8" class="fill-sky-600 dark:fill-sky-500" />
            <path d="M9 22V10l7 8 7-8v12" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
          <span class="text-lg font-semibold tracking-tight text-gray-900 dark:text-white">{{ brand }}</span>
        </slot>
      </a>

      <!-- Desktop -->
      <ul class="hidden flex-1 items-center gap-1 lg:flex">
        <li v-for="{ item, index } in topLevel" :key="item.label">
          <template v-if="hasPanel(item)">
            <button
              ref="triggerRefs"
              type="button"
              :data-index="index"
              :aria-expanded="openIndex === index"
              :aria-controls="panelId(index)"
              class="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium transition"
              :class="[focusRing, openIndex === index
                ? 'bg-sky-50 text-sky-700 dark:bg-sky-500/10 dark:text-sky-300'
                : 'text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white']"
              @click="toggle(index)"
              @keydown="onTriggerKeydown($event, index)"
            >
              {{ item.label }}
              <svg viewBox="0 0 20 20" aria-hidden="true" class="size-4 fill-current transition-transform" :class="openIndex === index && 'rotate-180'">
                <path fill-rule="evenodd" d="M5.2 7.2a.75.75 0 0 1 1.06 0L10 10.94l3.74-3.74a.75.75 0 1 1 1.06 1.06l-4.27 4.27a.75.75 0 0 1-1.06 0L5.2 8.26a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
              </svg>
            </button>
          </template>
          <a
            v-else
            ref="triggerRefs"
            :data-index="index"
            :href="item.href"
            class="inline-flex rounded-lg px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
            :class="focusRing"
            @click="select(item.href ?? '#', item.label)"
            @keydown="onTriggerKeydown($event, index)"
          >{{ item.label }}</a>
        </li>
      </ul>

      <div class="ml-auto hidden items-center gap-2 lg:flex">
        <a v-if="loginLabel" :href="loginHref" class="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white" :class="focusRing">{{ loginLabel }}</a>
        <a :href="ctaHref" class="rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-500 dark:bg-sky-500 dark:text-gray-950 dark:hover:bg-sky-400" :class="focusRing">{{ ctaLabel }}</a>
      </div>

      <button
        ref="burgerRef"
        type="button"
        class="-mr-2 ml-auto inline-flex size-10 items-center justify-center rounded-lg text-gray-700 hover:bg-gray-100 lg:hidden dark:text-gray-200 dark:hover:bg-white/10"
        :class="focusRing"
        :aria-expanded="mobileOpen"
        :aria-controls="mobileId"
        @click="mobileOpen = !mobileOpen"
      >
        <span class="sr-only">{{ mobileOpen ? 'Close menu' : 'Open menu' }}</span>
        <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" class="size-6">
          <path v-if="mobileOpen" d="M6 6l12 12M18 6 6 18" />
          <path v-else d="M4 7h16M4 12h16M4 17h16" />
        </svg>
      </button>
    </nav>

    <!-- Desktop mega panels -->
    <template v-for="{ item, index } in topLevel" :key="`panel-${item.label}`">
      <Transition
        v-if="hasPanel(item)"
        enter-active-class="transition duration-150 ease-out"
        enter-from-class="-translate-y-1 opacity-0"
        leave-active-class="transition duration-100 ease-in"
        leave-to-class="-translate-y-1 opacity-0"
      >
        <div
          v-show="openIndex === index"
          :id="panelId(index)"
          ref="panelRefs"
          :data-index="index"
          class="absolute inset-x-0 top-full hidden border-b border-gray-200 bg-white shadow-xl shadow-gray-900/5 lg:block dark:border-white/10 dark:bg-gray-900 dark:shadow-black/40"
          @keydown="onPanelKeydown"
        >
          <div class="mx-auto grid max-w-7xl gap-8 px-8 py-8" :class="item.feature ? 'grid-cols-[1fr_20rem]' : 'grid-cols-1'">
            <div class="grid gap-8" :style="{ gridTemplateColumns: `repeat(${item.columns!.length}, minmax(0, 1fr))` }">
              <div v-for="column in item.columns" :key="column.heading">
                <h3 class="px-3 text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400">{{ column.heading }}</h3>
                <ul class="mt-3 space-y-1">
                  <li v-for="link in column.links" :key="link.href">
                    <a
                      :href="link.href"
                      class="group flex gap-4 rounded-xl p-3 transition hover:bg-gray-50 dark:hover:bg-white/5"
                      :class="focusRing"
                      @click="select(link.href, link.title)"
                    >
                      <span class="grid size-10 shrink-0 place-items-center rounded-lg bg-sky-50 text-sky-600 ring-1 ring-sky-100 transition group-hover:bg-sky-600 group-hover:text-white dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-500/20 dark:group-hover:bg-sky-500 dark:group-hover:text-gray-950">
                        <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-5">
                          <path :d="icons[link.icon ?? 'cube']" />
                        </svg>
                      </span>
                      <span>
                        <span class="block text-sm font-semibold text-gray-900 dark:text-white">{{ link.title }}</span>
                        <span class="mt-0.5 block text-sm text-gray-600 dark:text-gray-400">{{ link.description }}</span>
                      </span>
                    </a>
                  </li>
                </ul>
              </div>
            </div>

            <a
              v-if="item.feature"
              :href="item.feature.href"
              class="group relative flex flex-col overflow-hidden rounded-2xl bg-gradient-to-br from-sky-600 to-indigo-700 p-6 text-white"
              :class="focusRing"
              @click="select(item.feature.href, item.feature.title)"
            >
              <svg viewBox="0 0 200 120" aria-hidden="true" class="absolute -right-6 -bottom-4 w-56 opacity-30">
                <path d="M0 100 C 30 90, 45 40, 75 55 S 120 100, 145 50 S 185 20, 200 10" fill="none" stroke="white" stroke-width="3" />
                <path d="M0 110 C 40 100, 60 70, 90 80 S 140 110, 200 60" fill="none" stroke="white" stroke-width="2" stroke-dasharray="4 6" />
              </svg>
              <span class="w-fit rounded-full bg-white/15 px-2.5 py-1 text-xs font-semibold ring-1 ring-white/25">{{ item.feature.eyebrow }}</span>
              <span class="mt-4 text-lg font-semibold">{{ item.feature.title }}</span>
              <span class="mt-2 text-sm text-sky-100">{{ item.feature.description }}</span>
              <span class="mt-auto inline-flex items-center gap-1 pt-6 text-sm font-semibold">
                {{ item.feature.cta }}
                <span aria-hidden="true" class="transition group-hover:translate-x-0.5">&rarr;</span>
              </span>
            </a>
          </div>
        </div>
      </Transition>
    </template>

    <!-- Mobile accordion menu -->
    <div
      v-show="mobileOpen"
      :id="mobileId"
      class="absolute inset-x-0 top-full max-h-[calc(100dvh-4rem)] overflow-y-auto border-b border-gray-200 bg-white px-4 pt-2 pb-6 shadow-lg lg:hidden dark:border-white/10 dark:bg-gray-950"
    >
      <ul class="divide-y divide-gray-100 dark:divide-white/5">
        <li v-for="{ item, index } in topLevel" :key="`m-${item.label}`">
          <template v-if="hasPanel(item)">
            <h2>
              <button
                type="button"
                class="flex w-full items-center justify-between rounded-lg px-2 py-3 text-base font-medium text-gray-900 dark:text-white"
                :class="focusRing"
                :aria-expanded="mobileSection === index"
                :aria-controls="`${mobileId}-${index}`"
                @click="mobileSection = mobileSection === index ? null : index"
              >
                {{ item.label }}
                <svg viewBox="0 0 20 20" aria-hidden="true" class="size-5 fill-gray-400 transition-transform" :class="mobileSection === index && 'rotate-180'">
                  <path fill-rule="evenodd" d="M5.2 7.2a.75.75 0 0 1 1.06 0L10 10.94l3.74-3.74a.75.75 0 1 1 1.06 1.06l-4.27 4.27a.75.75 0 0 1-1.06 0L5.2 8.26a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
                </svg>
              </button>
            </h2>
            <div v-show="mobileSection === index" :id="`${mobileId}-${index}`" class="pb-3">
              <div v-for="column in item.columns" :key="column.heading" class="mt-2">
                <h3 class="px-2 text-xs font-semibold tracking-wider text-gray-500 uppercase dark:text-gray-400">{{ column.heading }}</h3>
                <ul class="mt-1">
                  <li v-for="link in column.links" :key="link.href">
                    <a :href="link.href" class="flex items-center gap-3 rounded-lg px-2 py-2 hover:bg-gray-50 dark:hover:bg-white/5" :class="focusRing" @click="select(link.href, link.title)">
                      <span class="grid size-8 shrink-0 place-items-center rounded-md bg-sky-50 text-sky-600 dark:bg-sky-500/10 dark:text-sky-300">
                        <svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="size-4.5">
                          <path :d="icons[link.icon ?? 'cube']" />
                        </svg>
                      </span>
                      <span class="text-sm font-medium text-gray-800 dark:text-gray-200">{{ link.title }}</span>
                    </a>
                  </li>
                </ul>
              </div>
            </div>
          </template>
          <a v-else :href="item.href" class="block rounded-lg px-2 py-3 text-base font-medium text-gray-900 dark:text-white" :class="focusRing" @click="select(item.href ?? '#', item.label)">{{ item.label }}</a>
        </li>
      </ul>
      <div class="mt-4 grid gap-2">
        <a v-if="loginLabel" :href="loginHref" class="rounded-lg px-3 py-2.5 text-center text-base font-medium text-gray-700 ring-1 ring-gray-200 dark:text-gray-200 dark:ring-white/15" :class="focusRing">{{ loginLabel }}</a>
        <a :href="ctaHref" class="rounded-lg bg-sky-600 px-3 py-2.5 text-center text-base font-semibold text-white hover:bg-sky-500 dark:bg-sky-500 dark:text-gray-950" :class="focusRing">{{ ctaLabel }}</a>
      </div>
    </div>
  </header>
</template>

Props

PropTypeDefaultDescription
brandstring'Northwind'Brand name shown next to the default logo mark.
itemsMegaNavItem[]Products, Resources, Pricing, CustomersTop-level items. Give an item columns (heading plus links with title, description, href, icon) and an optional feature card to make it a mega menu, or just an href for a plain link.
ctaLabelstring'Get a demo'Primary call-to-action text.
ctaHrefstring'#demo'Primary call-to-action link target.
loginLabelstring'Sign in'Secondary link next to the CTA. Pass an empty string to hide it.
loginHrefstring'#signin'Secondary link target.
defaultOpenstring''Label of a mega menu to show open on first render, handy for demos and docs.

Events

EventPayloadDescription
navigate(href: string, label: string)Fired when any link in the bar, a panel or the mobile menu is clicked.

Slots

SlotDescription
logoReplaces the default logo mark and brand name.
All navbars →
Preview of Responsive NavbarFree

Component · Navbars

Responsive Navbar

Responsive Vue 3 navbar with logo, links, a CTA and an accessible hamburger menu that closes on Escape. Tailwind CSS, dark mode, typed props.

by Vueplay
Preview of NavigatorFree

Component · Navbars

Navigator

A menu container that controls the styling and behavior of all nested links. Links can be nested infinitely to create multi-level menus.

Preview of Dropdown MenuFree

Component · Navigation

Dropdown Menu

Accessible Vue 3 dropdown menu button with icons, separators, headings, disabled items, arrow-key navigation and click-outside close. Tailwind CSS.

by Vueplay
Preview of FAQ AccordionFree

Component · Accordions & FAQs

FAQ Accordion

Accessible Vue 3 FAQ accordion with single or multiple open answers, smooth height animation and full ARIA wiring. Tailwind CSS, dark mode.

by Vueplay

Pro feature

Upgrade to Pro

Upgrade to unlock more of Vueplay. Cancel any time.

  • Custom domains with HTTPS
  • Private projects
  • No "Made with Vueplay" badge
  • GitHub sync

$120/year

Compare plansBilling settings