Component · Navbars
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.
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 Vueplayv1.0.0Updated 3 hours ago
A top navigation bar for marketing sites and SaaS apps: logo on the left, page links in the middle, a login link and primary call to action on the right. Below 768px the links fold into a hamburger menu that slides down under the bar.
The mobile menu follows the disclosure pattern: the toggle carries aria-expanded and aria-controls, focus moves to the first link when it opens, Escape closes it and returns focus to the toggle, and clicking outside or widening the viewport closes it too.
Pass your own links (mark one as current to get aria-current="page"), swap the logo through the logo slot, or replace the right-hand buttons with the actions slot. Set sticky to false if you don't want the translucent sticky header.
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
export interface NavLink {
label: string
href: string
/** Marks the link as the current page (adds aria-current="page"). */
current?: boolean
}
const props = withDefaults(defineProps<{
brand?: string
links?: NavLink[]
ctaLabel?: string
ctaHref?: string
/** Secondary text link shown next to the CTA. Pass an empty string to hide it. */
loginLabel?: string
loginHref?: string
/** Stick to the top of the viewport with a translucent, blurred background. */
sticky?: boolean
}>(), {
brand: 'Lumen',
links: () => [
{ label: 'Product', href: '#product', current: true },
{ label: 'Features', href: '#features' },
{ label: 'Pricing', href: '#pricing' },
{ label: 'Customers', href: '#customers' },
{ label: 'Docs', href: '#docs' },
],
ctaLabel: 'Start free trial',
ctaHref: '#signup',
loginLabel: 'Log in',
loginHref: '#login',
sticky: true,
})
const emit = defineEmits<{
navigate: [link: NavLink]
cta: []
}>()
const open = ref(false)
const menuId = useId()
const toggleRef = ref<HTMLButtonElement | null>(null)
const panelRef = ref<HTMLElement | null>(null)
function close(returnFocus = false) {
if (!open.value) return
open.value = false
if (returnFocus) toggleRef.value?.focus()
}
function onNavigate(link: NavLink) {
emit('navigate', link)
close()
}
function onCta() {
emit('cta')
close()
}
// Move focus into the panel when it opens so keyboard users land on the first link.
watch(open, async (isOpen) => {
if (!isOpen) return
await nextTick()
panelRef.value?.querySelector<HTMLElement>('a, button')?.focus()
})
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && open.value) {
e.preventDefault()
close(true)
}
}
function onPointerDown(e: PointerEvent) {
if (!open.value) return
const target = e.target as Node
if (panelRef.value?.contains(target) || toggleRef.value?.contains(target)) return
close()
}
// Close the mobile panel if the viewport grows past the breakpoint.
let mq: MediaQueryList | undefined
const onBreakpoint = (e: MediaQueryListEvent) => { if (e.matches) close() }
onMounted(() => {
document.addEventListener('keydown', onKeydown)
document.addEventListener('pointerdown', onPointerDown)
mq = window.matchMedia('(min-width: 768px)')
mq.addEventListener('change', onBreakpoint)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown)
document.removeEventListener('pointerdown', onPointerDown)
mq?.removeEventListener('change', onBreakpoint)
})
</script>
<template>
<header
class="z-40 w-full border-b border-gray-200/80 dark:border-white/10"
:class="props.sticky ? 'sticky top-0 bg-white/80 backdrop-blur-md dark:bg-gray-950/80' : 'relative bg-white dark:bg-gray-950'"
>
<nav aria-label="Main" class="mx-auto flex h-16 max-w-7xl items-center justify-between gap-6 px-4 sm:px-6 lg:px-8">
<a href="#" class="flex shrink-0 items-center gap-2 rounded-md focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-violet-600">
<slot name="logo">
<span class="grid size-8 place-items-center rounded-lg bg-gradient-to-br from-violet-500 to-violet-700 shadow-sm shadow-violet-600/30">
<svg viewBox="0 0 24 24" aria-hidden="true" class="size-4.5 fill-white">
<path d="M12 2.5a1 1 0 0 1 .92.61l2.1 4.87 4.87 2.1a1 1 0 0 1 0 1.84l-4.87 2.1-2.1 4.87a1 1 0 0 1-1.84 0l-2.1-4.87-4.87-2.1a1 1 0 0 1 0-1.84l4.87-2.1 2.1-4.87A1 1 0 0 1 12 2.5Z" />
</svg>
</span>
<span class="text-lg font-semibold tracking-tight text-gray-900 dark:text-white">{{ brand }}</span>
</slot>
</a>
<ul class="hidden items-center gap-1 md:flex">
<li v-for="link in links" :key="link.href">
<a
:href="link.href"
:aria-current="link.current ? 'page' : undefined"
class="rounded-lg px-3 py-2 text-sm font-medium transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600"
:class="link.current
? 'text-violet-700 dark:text-violet-300'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white'"
@click="emit('navigate', link)"
>{{ link.label }}</a>
</li>
</ul>
<div class="hidden items-center gap-2 md:flex">
<slot name="actions">
<a
v-if="loginLabel"
:href="loginHref"
class="rounded-lg px-3 py-2 text-sm font-medium text-gray-700 transition hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 dark:text-gray-300 dark:hover:text-white"
>{{ loginLabel }}</a>
<a
:href="ctaHref"
class="rounded-lg bg-violet-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-violet-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 dark:bg-violet-500 dark:hover:bg-violet-400"
@click="emit('cta')"
>{{ ctaLabel }}</a>
</slot>
</div>
<button
ref="toggleRef"
type="button"
class="-mr-2 inline-flex size-10 items-center justify-center rounded-lg text-gray-700 transition hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 md:hidden dark:text-gray-200 dark:hover:bg-white/10"
:aria-expanded="open"
:aria-controls="menuId"
@click="open = !open"
>
<span class="sr-only">{{ open ? 'Close menu' : 'Open menu' }}</span>
<svg v-if="!open" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" class="size-6">
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" class="size-6">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</nav>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="-translate-y-2 opacity-0"
leave-active-class="transition duration-150 ease-in"
leave-to-class="-translate-y-2 opacity-0"
>
<div
v-show="open"
:id="menuId"
ref="panelRef"
class="absolute inset-x-0 top-full border-b border-gray-200 bg-white px-4 pt-2 pb-5 shadow-lg md:hidden dark:border-white/10 dark:bg-gray-950"
>
<ul class="space-y-1">
<li v-for="link in links" :key="link.href">
<a
:href="link.href"
:aria-current="link.current ? 'page' : undefined"
class="block rounded-lg px-3 py-2.5 text-base font-medium transition focus-visible:outline-2 focus-visible:outline-violet-600"
:class="link.current
? 'bg-violet-50 text-violet-700 dark:bg-violet-500/10 dark:text-violet-300'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-white/5'"
@click="onNavigate(link)"
>{{ link.label }}</a>
</li>
</ul>
<div class="mt-4 grid gap-2 border-t border-gray-200 pt-4 dark:border-white/10">
<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 transition hover:bg-gray-50 focus-visible:outline-2 focus-visible:outline-violet-600 dark:text-gray-200 dark:ring-white/15 dark:hover:bg-white/5"
@click="close()"
>{{ loginLabel }}</a>
<a
:href="ctaHref"
class="rounded-lg bg-violet-600 px-3 py-2.5 text-center text-base font-semibold text-white transition hover:bg-violet-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 dark:bg-violet-500 dark:hover:bg-violet-400"
@click="onCta"
>{{ ctaLabel }}</a>
</div>
</div>
</Transition>
</header>
</template>
| Prop | Type | Default | Description |
|---|---|---|---|
| brand | string | 'Lumen' | Brand name shown next to the default logo mark. |
| links | NavLink[] | Product, Features, Pricing, Customers, Docs | Navigation links. Each has label, href and an optional current flag. |
| ctaLabel | string | 'Start free trial' | Text of the primary call-to-action button. |
| ctaHref | string | '#signup' | Link target of the call-to-action button. |
| loginLabel | string | 'Log in' | Secondary link next to the CTA. Pass an empty string to hide it. |
| loginHref | string | '#login' | Link target of the secondary link. |
| sticky | boolean | true | Stick the header to the top of the viewport with a translucent blurred background. |
| Event | Payload | Description |
|---|---|---|
| navigate | (link: NavLink) | Fired when a navigation link is clicked, on desktop or in the mobile menu. |
| cta | () | Fired when the call-to-action button is clicked. |
| Slot | Description |
|---|---|
| logo | Replaces the default logo mark and brand name. |
| actions | Replaces the login link and CTA on desktop. |
Component · Navbars
Vue 3 navbar with Products and Resources mega-menu panels, keyboard navigation and a mobile accordion. Built with Tailwind CSS, dark mode ready.
Component · Navbars
A menu container that controls the styling and behavior of all nested links. Links can be nested infinitely to create multi-level menus.
Component · Accordions & FAQs
Accessible Vue 3 FAQ accordion with single or multiple open answers, smooth height animation and full ARIA wiring. Tailwind CSS, dark mode.
Component · Testimonials
Accessible Vue 3 testimonial carousel with prev/next, dots and autoplay that pauses on hover or focus and honours reduced motion. Tailwind CSS.