Navigation Menu
A collection of links for navigating websites with support for dropdown menus and keyboard navigation.
Features
Install
Install the component from your command line.
Anatomy
Import all parts and piece them together.
<script setup lang="ts">import * as navigationMenu from '@destyler/navigation-menu'import { normalizeProps, useMachine } from '@destyler/vue'import { computed, useId } from 'vue'
const [state, send] = useMachine(navigationMenu.machine({ id: useId() }))
const api = computed(() => navigationMenu.connect(state.value, send, normalizeProps),)</script>
<template> <nav v-bind="api.getRootProps()"> <ul v-bind="api.getListProps()"> <li v-bind="api.getItemProps({ value: 'products' })"> <button v-bind="api.getTriggerProps({ value: 'products' })"> Products </button> </li> <li v-bind="api.getItemProps({ value: 'docs' })"> <a v-bind="api.getLinkProps({ value: 'docs' })" href="#"> Documentation </a> </li> </ul>
<div v-bind="api.getViewportPositionerProps()"> <div v-if="api.open" v-bind="api.getViewportProps()"> <div v-bind="api.getContentProps({ value: 'products' })"> <!-- Products content --> </div> </div> </div> </nav></template>import * as navigationMenu from '@destyler/navigation-menu'import { normalizeProps, useMachine } from '@destyler/react'import { useId } from 'react'
export default function NavigationMenu() { const [state, send] = useMachine(navigationMenu.machine({ id: useId() })) const api = navigationMenu.connect(state, send, normalizeProps)
return ( <nav {...api.getRootProps()}> <ul {...api.getListProps()}> <li {...api.getItemProps({ value: 'products' })}> <button {...api.getTriggerProps({ value: 'products' })}> Products </button> </li> <li {...api.getItemProps({ value: 'docs' })}> <a {...api.getLinkProps({ value: 'docs' })} href="#"> Documentation </a> </li> </ul>
<div {...api.getViewportPositionerProps()}> {api.open && ( <div {...api.getViewportProps()}> <div {...api.getContentProps({ value: 'products' })}> {/* Products content */} </div> </div> )} </div> </nav> )}<script lang="ts"> import * as navigationMenu from '@destyler/navigation-menu' import { normalizeProps, useMachine } from '@destyler/svelte'
const id = $props.id()
const [state, send] = useMachine(navigationMenu.machine({ id }))
const api = $derived(navigationMenu.connect(state, send, normalizeProps))</script>
<nav {...api.getRootProps()}> <ul {...api.getListProps()}> <li {...api.getItemProps({ value: 'products' })}> <button {...api.getTriggerProps({ value: 'products' })}> Products </button> </li> <li {...api.getItemProps({ value: 'docs' })}> <a {...api.getLinkProps({ value: 'docs' })} href="#"> Documentation </a> </li> </ul>
<div {...api.getViewportPositionerProps()}> {#if api.open} <div {...api.getViewportProps()}> <div {...api.getContentProps({ value: 'products' })}> <!-- Products content --> </div> </div> {/if} </div></nav>import * as navigationMenu from '@destyler/navigation-menu'import { normalizeProps, useMachine } from '@destyler/solid'import { createMemo, createUniqueId, Show } from 'solid-js'
export default function NavigationMenu() { const [state, send] = useMachine(navigationMenu.machine({ id: createUniqueId() })) const api = createMemo(() => navigationMenu.connect(state, send, normalizeProps))
return ( <nav {...api().getRootProps()}> <ul {...api().getListProps()}> <li {...api().getItemProps({ value: 'products' })}> <button {...api().getTriggerProps({ value: 'products' })}> Products </button> </li> <li {...api().getItemProps({ value: 'docs' })}> <a {...api().getLinkProps({ value: 'docs' })} href="#"> Documentation </a> </li> </ul>
<div {...api().getViewportPositionerProps()}> <Show when={api().open}> <div {...api().getViewportProps()}> <div {...api().getContentProps({ value: 'products' })}> {/* Products content */} </div> </div> </Show> </div> </nav> )}Trigger mode
By default, menu items open on hover. You can change this to click-to-open behavior:
const [state, send] = useMachine( navigationMenu.machine({ triggerMode: 'click', }),)Orientation
The navigation menu supports both horizontal and vertical layouts:
const [state, send] = useMachine( navigationMenu.machine({ orientation: 'vertical', }),)Controlling the open state
You can control which menu item is open using the value property:
const [state, send] = useMachine( navigationMenu.machine({ value: 'products', // Opens the products menu initially onValueChange(details) { console.log('Open menu changed to:', details.value) }, }),)Using links without content
For navigation items that are just links (no dropdown content), use the link pattern:
<template> <ul v-bind="api.getListProps()"> <!-- Item with dropdown --> <li v-bind="api.getItemProps({ value: 'products' })"> <button v-bind="api.getTriggerProps({ value: 'products' })">Products</button> </li> <!-- Simple link (no dropdown) --> <li v-bind="api.getItemProps({ value: 'docs' })"> <a v-bind="api.getLinkProps({ value: 'docs' })" href="/docs"> Documentation </a> </li> </ul></template><ul {...api.getListProps()}> {/* Item with dropdown */} <li {...api.getItemProps({ value: 'products' })}> <button {...api.getTriggerProps({ value: 'products' })}>Products</button> </li> {/* Simple link (no dropdown) */} <li {...api.getItemProps({ value: 'docs' })}> <a {...api.getLinkProps({ value: 'docs' })} href="/docs"> Documentation </a> </li></ul><ul {...api.getListProps()}> <!-- Item with dropdown --> <li {...api.getItemProps({ value: 'products' })}> <button {...api.getTriggerProps({ value: 'products' })}>Products</button> </li> <!-- Simple link (no dropdown) --> <li {...api.getItemProps({ value: 'docs' })}> <a {...api.getLinkProps({ value: 'docs' })} href="/docs"> Documentation </a> </li></ul><ul {...api().getListProps()}> {/* Item with dropdown */} <li {...api().getItemProps({ value: 'products' })}> <button {...api().getTriggerProps({ value: 'products' })}>Products</button> </li> {/* Simple link (no dropdown) */} <li {...api().getItemProps({ value: 'docs' })}> <a {...api().getLinkProps({ value: 'docs' })} href="/docs"> Documentation </a> </li></ul>Viewport pattern
For smooth transitions between content panels, use the viewport pattern. All content panels are rendered inside a shared viewport container:
<template> <nav v-bind="api.getRootProps()"> <ul v-bind="api.getListProps()"> <!-- triggers... --> </ul>
<div v-bind="api.getViewportPositionerProps()"> <div v-if="api.open" v-bind="api.getViewportProps()"> <!-- All content panels stacked --> <div v-bind="api.getContentProps({ value: 'products' })"> <!-- Products content --> </div> <div v-bind="api.getContentProps({ value: 'resources' })"> <!-- Resources content --> </div> </div> </div> </nav></template><nav {...api.getRootProps()}> <ul {...api.getListProps()}> {/* triggers... */} </ul>
<div {...api.getViewportPositionerProps()}> {api.open && ( <div {...api.getViewportProps()}> {/* All content panels stacked */} <div {...api.getContentProps({ value: 'products' })}> {/* Products content */} </div> <div {...api.getContentProps({ value: 'resources' })}> {/* Resources content */} </div> </div> )} </div></nav><nav {...api.getRootProps()}> <ul {...api.getListProps()}> <!-- triggers... --> </ul>
<div {...api.getViewportPositionerProps()}> {#if api.open} <div {...api.getViewportProps()}> <!-- All content panels stacked --> <div {...api.getContentProps({ value: 'products' })}> <!-- Products content --> </div> <div {...api.getContentProps({ value: 'resources' })}> <!-- Resources content --> </div> </div> {/if} </div></nav><nav {...api().getRootProps()}> <ul {...api().getListProps()}> {/* triggers... */} </ul>
<div {...api().getViewportPositionerProps()}> <Show when={api().open}> <div {...api().getViewportProps()}> {/* All content panels stacked */} <div {...api().getContentProps({ value: 'products' })}> {/* Products content */} </div> <div {...api().getContentProps({ value: 'resources' })}> {/* Resources content */} </div> </div> </Show> </div></nav>Styling Guide
Each navigation menu part has a
data-partattribute added to them to select and style them in the DOM.
Trigger states
Style triggers based on their open/closed state:
[data-scope="navigation-menu"][data-part="trigger"][data-state="open"] { background-color: #f1f5f9;}
[data-scope="navigation-menu"][data-part="trigger"][data-state="closed"] { background-color: transparent;}Content animations
The content panels have data-motion attribute for directional animations:
/* Entering from left */[data-scope="navigation-menu"][data-part="content"][data-motion="from-start"] { animation: enterFromLeft 200ms ease;}
/* Entering from right */[data-scope="navigation-menu"][data-part="content"][data-motion="from-end"] { animation: enterFromRight 200ms ease;}
/* Exiting to left */[data-scope="navigation-menu"][data-part="content"][data-motion="to-start"] { animation: exitToLeft 200ms ease;}
/* Exiting to right */[data-scope="navigation-menu"][data-part="content"][data-motion="to-end"] { animation: exitToRight 200ms ease;}Viewport animations
Style the viewport container with 3D perspective animations:
[data-scope="navigation-menu"][data-part="viewport"] { transform-origin: top center; transition: width 200ms ease, height 200ms ease;}
[data-scope="navigation-menu"][data-part="viewport"][data-state="open"] { animation: viewportEnter 200ms ease;}
[data-scope="navigation-menu"][data-part="viewport"][data-state="closed"] { animation: viewportExit 150ms ease;}
@keyframes viewportEnter { from { opacity: 0; transform: rotateX(-10deg) scale(0.95); } to { opacity: 1; transform: rotateX(0deg) scale(1); }}Grid stacking for smooth transitions
Use CSS Grid to stack content panels for seamless transitions:
[data-scope="navigation-menu"][data-part="viewport"] { display: grid;}
[data-scope="navigation-menu"][data-part="viewport"] > * { grid-area: 1 / 1;}
[data-scope="navigation-menu"][data-part="content"][hidden] { display: none;}Methods and Properties
Machine Context
The navigation menu machine exposes the following context properties:
Partial<{ root: string; list: string; viewport: string; viewportPositioner: string; indicator: string; trigger: (value: string) => string; link: (value: string) => string; content: (value: string) => string; item: (value: string) => string; }>stringstring(details: ValueChangeDetails) => voidnumbernumberOrientationbooleanbooleanbooleanboolean"ltr" | "rtl"string() => ShadowRoot | Node | DocumentMachine API
The navigation menu api exposes the following methods:
stringstringboolean(value: string) => void(props: TriggerProps) => { open: boolean; disabled: boolean; }(props: ContentProps) => { open: boolean; motion: "from-start" | "from-end" | "to-start" | "to-end"; }Data Attributes
Root
data-scopedata-partdata-orientationList
data-scopedata-partdata-orientationItem
data-scopedata-partdata-statedata-valueTrigger
data-scopedata-partdata-disableddata-statedata-valueLink
data-scopedata-partdata-activeContent
data-scopedata-partdata-statedata-motiondata-valuedata-orientationViewport
data-scopedata-partdata-statedata-orientationViewportPositioner
data-scopedata-partdata-statedata-orientationIndicator
data-scopedata-partdata-statedata-orientationArrow
data-scopedata-partdata-statedata-orientationAccessibility
Keyboard Interactions
Tab: Moves focus to the next focusable elementShift + Tab: Moves focus to the previous focusable elementEnter: When focus is on a trigger, opens/closes the associated content. When focus is on a link, activates the link.Space: When focus is on a trigger, opens/closes the associated contentArrowRight: In horizontal orientation, moves focus to the next itemArrowLeft: In horizontal orientation, moves focus to the previous itemArrowDown: In vertical orientation, moves focus to the next item. When a dropdown is open, moves focus into the content.ArrowUp: In vertical orientation, moves focus to the previous itemEscape: Closes any open content and returns focus to the triggerHome: Moves focus to the first navigation itemEnd: Moves focus to the last navigation item
ARIA Attributes
- The root element has
role="navigation"for screen readers - Triggers have
aria-expandedandaria-controlsattributes - Content panels have proper
roleandaria-labelledbyattributes - Links have
data-activeattribute when representing the current page