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 initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add navigation-menupnpm dlx ermineui add navigation-menuyarn dlx ermineui add navigation-menubunx ermineui add navigation-menuThat 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
| File | Where it goes |
|---|---|
NavigationMenu.vue | src/components/ui/navigation-menu/NavigationMenu.vue |
NavigationMenuContent.vue | src/components/ui/navigation-menu/NavigationMenuContent.vue |
NavigationMenuItem.vue | src/components/ui/navigation-menu/NavigationMenuItem.vue |
NavigationMenuLink.vue | src/components/ui/navigation-menu/NavigationMenuLink.vue |
NavigationMenuTrigger.vue | src/components/ui/navigation-menu/NavigationMenuTrigger.vue |
context.ts | src/components/ui/navigation-menu/context.ts |
index.ts | src/components/ui/navigation-menu/index.ts |
context.ts helper | src/lib/ermine/context.ts |
primitive.ts helper | src/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'