Vue componentFree

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 Vueplayv1.0.0Updated 3 hours ago

Install
Full screen ↗

About this component

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.

  • Hamburger menu with Escape and outside-click close
  • Focus moves in and back out of the menu
  • aria-current on the active link
  • Sticky, blurred header option
  • Light and dark mode

Code

11 files in the repo
<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>

Props

PropTypeDefaultDescription
brandstring'Lumen'Brand name shown next to the default logo mark.
linksNavLink[]Product, Features, Pricing, Customers, DocsNavigation links. Each has label, href and an optional current flag.
ctaLabelstring'Start free trial'Text of the primary call-to-action button.
ctaHrefstring'#signup'Link target of the call-to-action button.
loginLabelstring'Log in'Secondary link next to the CTA. Pass an empty string to hide it.
loginHrefstring'#login'Link target of the secondary link.
stickybooleantrueStick the header to the top of the viewport with a translucent blurred background.

Events

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

Slots

SlotDescription
logoReplaces the default logo mark and brand name.
actionsReplaces the login link and CTA on desktop.
All navbars →
Preview of Mega Menu NavbarFree

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.

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 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
Preview of Testimonial CarouselFree

Component · Testimonials

Testimonial Carousel

Accessible Vue 3 testimonial carousel with prev/next, dots and autoplay that pauses on hover or focus and honours reduced motion. Tailwind CSS.

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