Navigation menu

A site's main links across the top, some of which open a panel of further links.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add navigation-menu
pnpm dlx ermineui add navigation-menu
yarn dlx ermineui add navigation-menu
bunx ermineui add navigation-menu

That copies 7 files into src/components/ui/navigation-menu/, 2 helpers into src/lib/ermine/. Files you already have are kept.

Use

import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuTrigger,
} from '@/components/ui/navigation-menu'
<NavigationMenu>
  <NavigationMenuItem>
    <NavigationMenuTrigger>Product</NavigationMenuTrigger>
    <NavigationMenuContent>
      <NavigationMenuLink href="/charts" description="Line, bar and more.">Charts</NavigationMenuLink>
    </NavigationMenuContent>
  </NavigationMenuItem>
  <NavigationMenuItem><NavigationMenuLink href="/pricing" active>Pricing</NavigationMenuLink></NavigationMenuItem>
</NavigationMenu>

<Menubar label="Editor">
  <MenubarMenu>
    <MenubarTrigger>File</MenubarTrigger>
    <MenubarContent>
      <DropdownMenuItem @select="save">Save</DropdownMenuItem>
    </MenubarContent>
  </MenubarMenu>
</Menubar>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
NavigationMenu.vuesrc/components/ui/navigation-menu/NavigationMenu.vue
NavigationMenuContent.vuesrc/components/ui/navigation-menu/NavigationMenuContent.vue
NavigationMenuItem.vuesrc/components/ui/navigation-menu/NavigationMenuItem.vue
NavigationMenuLink.vuesrc/components/ui/navigation-menu/NavigationMenuLink.vue
NavigationMenuTrigger.vuesrc/components/ui/navigation-menu/NavigationMenuTrigger.vue
context.tssrc/components/ui/navigation-menu/context.ts
index.tssrc/components/ui/navigation-menu/index.ts
context.ts helpersrc/lib/ermine/context.ts
primitive.ts helpersrc/lib/ermine/primitive.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

NavigationMenu.vue 192 lines
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { provideNavigationMenu } from './context'

withDefaults(
  defineProps<{
    /** Accessible name of the navigation. */
    label?: string
  }>(),
  { label: 'Main' },
)

const root = ref<HTMLElement | null>(null)
const openId = ref<string>()
let timer: ReturnType<typeof setTimeout> | undefined

function cancelClose() {
  clearTimeout(timer)
}

function open(id: string, immediate = false) {
  cancelClose()
  // With a panel already showing, moving along the bar switches at once; from closed there is a short pause.
  if (immediate || openId.value !== undefined) openId.value = id
  else timer = setTimeout(() => (openId.value = id), 120)
}

function close(immediate = false) {
  cancelClose()
  if (immediate) openId.value = undefined
  else timer = setTimeout(() => (openId.value = undefined), 180)
}

provideNavigationMenu({ openId, open, close, cancelClose })

function onPointerDown(event: PointerEvent) {
  if (event.target instanceof Node && !root.value?.contains(event.target)) close(true)
}

// Listened for on the whole page: a panel opened by hovering has to close with Escape wherever focus is.
function onKeydown(event: KeyboardEvent) {
  if (event.key !== 'Escape' || openId.value === undefined) return
  const trigger = root.value?.querySelector<HTMLElement>('.er-navigation-menu-trigger[aria-expanded="true"]')
  const focusInside = root.value?.contains(document.activeElement)
  close(true)
  // Focus goes back to the button only if it was in the menu; a hover should not steal it from elsewhere.
  if (focusInside) trigger?.focus()
}

watch(openId, (id, _previous, onCleanup) => {
  if (id === undefined) return
  document.addEventListener('pointerdown', onPointerDown, true)
  document.addEventListener('keydown', onKeydown)
  onCleanup(() => {
    document.removeEventListener('pointerdown', onPointerDown, true)
    document.removeEventListener('keydown', onKeydown)
  })
})

function onFocusOut(event: FocusEvent) {
  // Tabbing out of the bar and its panel closes the panel.
  if (event.relatedTarget instanceof Node && !root.value?.contains(event.relatedTarget)) close(true)
}

onBeforeUnmount(cancelClose)
</script>

<template>
  <nav ref="root" class="er-navigation-menu" :aria-label="label" @focusout="onFocusOut">
    <ul><slot /></ul>
  </nav>
</template>

<style>
@layer components {
  .er-navigation-menu > ul {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.125rem;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .er-navigation-menu-item {
    position: relative;
  }

  .er-navigation-menu-trigger,
  .er-navigation-menu-item > .er-navigation-menu-link {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    height: var(--er-control-md);
    padding: 0 0.75rem;
    border: 0;
    border-radius: var(--er-radius-control);
    background: transparent;
    color: var(--er-foreground);
    font-family: inherit;
    font-size: 0.875rem;
    font-weight: var(--er-weight-medium);
    text-decoration: none;
    white-space: nowrap;
    cursor: pointer;
    transition: background-color var(--er-duration);
  }

  .er-navigation-menu-trigger:hover,
  .er-navigation-menu-trigger[aria-expanded='true'],
  .er-navigation-menu-item > .er-navigation-menu-link:hover {
    background: var(--er-accent);
    color: var(--er-accent-foreground);
  }

  .er-navigation-menu-link[aria-current='page'] {
    font-weight: var(--er-weight-semibold);
  }

  .er-navigation-menu-trigger:focus-visible,
  .er-navigation-menu-link:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 2px;
  }

  .er-navigation-menu-trigger svg {
    width: 0.875rem;
    height: 0.875rem;
    color: var(--er-muted-foreground);
    transition: transform var(--er-duration) var(--er-ease);
  }

  .er-navigation-menu-trigger[aria-expanded='true'] svg {
    transform: rotate(180deg);
  }

  /* The panel sits in the page right after its button, so Tab moves from the button straight into it. */
  .er-navigation-menu-content {
    position: absolute;
    top: calc(100% + 0.375rem);
    left: 0;
    z-index: var(--er-z-overlay);
    width: max-content;
    max-width: min(32rem, calc(100vw - 2rem));
    padding: 0.5rem;
    border: 1px solid var(--er-border);
    border-radius: var(--er-radius-lg);
    background: var(--er-popover);
    color: var(--er-popover-foreground);
    box-shadow: var(--er-shadow-lg);
  }

  .er-navigation-menu-content[data-align='end'] {
    right: 0;
    left: auto;
  }

  /* A bridge across the gap, so the pointer does not "leave" on its way from the button to the panel. */
  .er-navigation-menu-content::before {
    content: '';
    position: absolute;
    inset: -0.5rem 0 auto;
    height: 0.5rem;
  }

  .er-navigation-menu-content .er-navigation-menu-link {
    display: grid;
    gap: 0.125rem;
    padding: 0.5rem 0.625rem;
    border-radius: var(--er-radius-md);
    color: inherit;
    font-size: 0.875rem;
    font-weight: var(--er-weight-medium);
    text-decoration: none;
  }

  .er-navigation-menu-content .er-navigation-menu-link:hover {
    background: var(--er-accent);
    color: var(--er-accent-foreground);
  }

  .er-navigation-menu-description {
    color: var(--er-muted-foreground);
    font-size: 0.8125rem;
    font-weight: 400;
    line-height: 1.4;
    white-space: normal;
  }
}
</style>
NavigationMenuContent.vue 31 lines
<script setup lang="ts">
import { useNavigationMenu, useNavigationMenuItem } from './context'

withDefaults(
  defineProps<{
    /** Line the panel up with the start or the end of its button. Use "end" for items near the right edge. */
    align?: 'start' | 'end'
  }>(),
  { align: 'start' },
)

const menu = useNavigationMenu()
const item = useNavigationMenuItem()
</script>

<template>
  <Transition name="er-pop">
    <div
      v-show="item.isOpen.value"
      :id="item.contentId"
      class="er-navigation-menu-content"
      :aria-labelledby="item.triggerId"
      :data-align="align"
      @pointerenter="menu.cancelClose()"
      @pointerleave="menu.close()"
    >
      <slot />
    </div>
  </Transition>
</template>
NavigationMenuItem.vue 17 lines
<script setup lang="ts">
import { computed, useId } from 'vue'
import { provideNavigationMenuItem, useNavigationMenu } from './context'

const menu = useNavigationMenu()
const id = useId()
const isOpen = computed(() => menu.openId.value === id)

provideNavigationMenuItem({ id, isOpen, triggerId: `${id}-trigger`, contentId: `${id}-content` })
</script>

<template>
  <li class="er-navigation-menu-item" :data-state="isOpen ? 'open' : 'closed'">
    <slot :open="isOpen" />
  </li>
</template>
NavigationMenuLink.vue 27 lines
<script setup lang="ts">
import type { Component } from 'vue'
import { Primitive } from '../../lib/primitive'
import { useNavigationMenu } from './context'

withDefaults(
  defineProps<{
    /** Tag or component for the link, e.g. NuxtLink. */
    as?: string | Component
    /** The page the reader is on now. */
    active?: boolean
    /** A second line under the link's name, for links inside a panel. */
    description?: string
  }>(),
  { as: 'a' },
)

const menu = useNavigationMenu()
</script>

<template>
  <Primitive :as="as" class="er-navigation-menu-link" :aria-current="active ? 'page' : undefined" @click="menu.close(true)">
    <span><slot /></span>
    <span v-if="description" class="er-navigation-menu-description">{{ description }}</span>
  </Primitive>
</template>
NavigationMenuTrigger.vue 45 lines
<script setup lang="ts">
import { nextTick } from 'vue'
import { useNavigationMenu, useNavigationMenuItem } from './context'

const menu = useNavigationMenu()
const item = useNavigationMenuItem()

function onPointerEnter(event: PointerEvent) {
  // Touch has no hover: a tap is a click, handled below.
  if (event.pointerType !== 'touch') menu.open(item.id)
}

function onClick() {
  if (item.isOpen.value) menu.close(true)
  else menu.open(item.id, true)
}

async function onKeydown(event: KeyboardEvent) {
  if (event.key !== 'ArrowDown') return
  event.preventDefault()
  menu.open(item.id, true)
  await nextTick()
  document.getElementById(item.contentId)?.querySelector<HTMLElement>('a[href], button:not([disabled])')?.focus()
}
</script>

<template>
  <button
    :id="item.triggerId"
    type="button"
    class="er-navigation-menu-trigger"
    :aria-expanded="item.isOpen.value"
    :aria-controls="item.contentId"
    @pointerenter="onPointerEnter"
    @pointerleave="menu.close()"
    @click="onClick"
    @keydown="onKeydown"
  >
    <slot />
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="m6 9 6 6 6-6" />
    </svg>
  </button>
</template>
context.ts 23 lines
import type { Ref } from 'vue'
import { createContext } from '../../lib/context'

export interface NavigationMenuContext {
  /** id of the item whose panel is open. Only one is open at a time. */
  openId: Ref<string | undefined>
  /** Open a panel now, or after the short pause that tells a pass-over from a real hover. */
  open: (id: string, immediate?: boolean) => void
  /** Close after a moment (time to move into the panel), or at once. */
  close: (immediate?: boolean) => void
  cancelClose: () => void
}

export interface NavigationMenuItemContext {
  id: string
  isOpen: Ref<boolean>
  triggerId: string
  contentId: string
}

export const [provideNavigationMenu, useNavigationMenu] = createContext<NavigationMenuContext>('NavigationMenu')
export const [provideNavigationMenuItem, useNavigationMenuItem] = createContext<NavigationMenuItemContext>('NavigationMenuItem')
index.ts 7 lines
/** Navigation menu — a site's main links across the top, some of which open a panel of further links. */
export { default as NavigationMenu } from './NavigationMenu.vue'
export { default as NavigationMenuContent } from './NavigationMenuContent.vue'
export { default as NavigationMenuItem } from './NavigationMenuItem.vue'
export { default as NavigationMenuLink } from './NavigationMenuLink.vue'
export { default as NavigationMenuTrigger } from './NavigationMenuTrigger.vue'