Skip to content
Destyler Destyler Destyler

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.

Terminal window
      
        
npm install @destyler/navigation-menu @destyler/vue
Terminal window
      
        
npm install @destyler/navigation-menu @destyler/react
Terminal window
      
        
npm install @destyler/navigation-menu @destyler/svelte
Terminal window
      
        
npm install @destyler/navigation-menu @destyler/solid

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

Prefer defaultValue to open a menu initially without taking ownership. Pass value + onValueChange when your app owns state (presence-controlled):

const [state, send] = useMachine(
navigationMenu.machine({
defaultValue: 'products', // Opens the products menu initially (uncontrolled seed)
onValueChange(details) {
console.log('Open menu changed to:', details.value)
},
}),
)

Controlled usage

Navigation menu openness is encoded as value (which trigger is active). Prefer defaultValue for an uncontrolled seed. Passing value into machine(...) is presence-controlled — sync with onValueChange and adapter context. See Controllable State.

navigationMenu.machine({
id: "1",
defaultValue: "products",
})
const value = "products" // your reactive state
navigationMenu.machine({
id: "1",
value,
onValueChange(details) {
// details => { value: string | null }
},
})

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-part attribute 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:

ids
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; }>
The ids of the elements in the navigation menu. Useful for composition.
value
string
The controlled value of the currently active menu item. Prefer `defaultValue` for uncontrolled seeds (Phase 2 presence).
defaultValue
string
The initial value of the menu item to open when rendered (uncontrolled).
onValueChange
(details: ValueChangeDetails) => void
Function called when the active menu item changes.
openDelay(default: 200)
number
The delay (in ms) before the navigation menu opens when hovering over a trigger.
closeDelay(default: 300)
number
The delay (in ms) before the navigation menu closes when the pointer leaves.
orientation(default: "horizontal")
Orientation
The orientation of the navigation menu.
disableClickTrigger(default: false)
boolean
Whether to disable click interaction on triggers.
disableHoverTrigger(default: false)
boolean
Whether to disable hover interaction on triggers.
disablePointerLeaveClose(default: false)
boolean
Whether to disable closing when the pointer leaves the menu.
dir(default: "ltr")
"ltr" | "rtl"
The document's text/writing direction.
id
string
The unique identifier of the machine.
getRootNode
() => ShadowRoot | Node | Document
A root node to correctly resolve document in custom environments. E.x.: Iframes, Electron.

Machine API

The navigation menu api exposes the following methods:

value
string
The current active value
previousValue
string
The previously active value (for animation direction)
open
boolean
Whether a menu item is currently open
setValue
(value: string) => void
Function to set the value
getTriggerState
(props: TriggerProps) => { open: boolean; disabled: boolean; }
Returns the state of a trigger
getContentState
(props: ContentProps) => { open: boolean; motion: "from-start" | "from-end" | "to-start" | "to-end"; }
Returns the state of a content

Data Attributes

Root

attribute
description
data-scope
navigation-menu
data-part
root
data-orientation
The orientation of the navigation-menu

List

attribute
description
data-scope
navigation-menu
data-part
list
data-orientation
The orientation of the list

Item

attribute
description
data-scope
navigation-menu
data-part
item
data-state
"open" | "closed"
data-value
The value of the item

Trigger

attribute
description
data-scope
navigation-menu
data-part
trigger
data-disabled
Present when disabled
data-state
"open" | "closed"
data-value
The value of the item
attribute
description
data-scope
navigation-menu
data-part
link
data-active
Present when active or pressed

Content

attribute
description
data-scope
navigation-menu
data-part
content
data-state
"open" | "closed"
data-motion
data-value
The value of the item
data-orientation
The orientation of the content

Viewport

attribute
description
data-scope
navigation-menu
data-part
viewport
data-state
"open" | "closed"
data-orientation
The orientation of the viewport

ViewportPositioner

attribute
description
data-scope
navigation-menu
data-part
viewport-positioner
data-state
"open" | "closed"
data-orientation
The orientation of the viewportpositioner

Indicator

attribute
description
data-scope
navigation-menu
data-part
indicator
data-state
"open" | "closed"
data-orientation
The orientation of the indicator

Arrow

attribute
description
data-scope
navigation-menu
data-part
arrow
data-state
"open" | "closed"
data-orientation
The orientation of the arrow

Accessibility

Keyboard Interactions

  • Tab: Moves focus to the next focusable element
  • Shift + Tab: Moves focus to the previous focusable element
  • Enter: 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 content
  • ArrowRight: In horizontal orientation, moves focus to the next item
  • ArrowLeft: In horizontal orientation, moves focus to the previous item
  • ArrowDown: 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 item
  • Escape: Closes any open content and returns focus to the trigger
  • Home: Moves focus to the first navigation item
  • End: Moves focus to the last navigation item

ARIA Attributes

  • The root element has role="navigation" for screen readers
  • Triggers have aria-expanded and aria-controls attributes
  • Content panels have proper role and aria-labelledby attributes
  • Links have data-active attribute when representing the current page