Skip to content
Destyler UI Destyler UI Destyler UI

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

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)
},
}),
)

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 value of the currently active menu item.
defaultValue
string
The initial value of the menu item to open when rendered.
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.
value.controlled
boolean
Whether the value is controlled by the user
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