Skip to main content

Navigation Menu

An accessible navigation menu component that provides a list of links with optional dropdown content. Supports keyboard navigation, hover/click interactions, animated indicators, and follows WAI-ARIA practices.

Features

  • Support for basic (inline content) and viewport (shared viewport) patterns
  • Hover and click trigger support with configurable delays
  • Keyboard navigation with arrow keys, Tab, Home/End
  • Animated indicator that follows the active trigger
  • Smooth content animations with viewport positioning
  • Support for nested links within dropdown content
  • Horizontal and vertical orientation
  • RTL (right-to-left) support
  • Fully managed focus and tab order
  • Dismissible with click outside or Escape key

Installation

To use the navigation menu machine in your project, run the following command in your command line:

npm install @zag-js/navigation-menu @zag-js/svelte # or yarn add @zag-js/navigation-menu @zag-js/svelte

Anatomy

To set up the navigation menu correctly, you'll need to understand its anatomy and how we name its parts.

Each part includes a data-part attribute to help identify them in the DOM.

No anatomy available for navigation-menu

Usage

First, import the navigation menu package into your project

import * as navigationMenu from "@zag-js/navigation-menu"

The navigation menu package exports two key functions:

  • machine — The state machine logic for the navigation menu widget.
  • connect — The function that translates the machine's state to JSX attributes and event handlers.

You'll need to provide a unique id to the useMachine hook. This is used to ensure that every part has a unique identifier.

Next, import the required hooks and functions for your framework and use the navigation menu machine in your project 🔥

<script lang="ts"> import * as navigationMenu from "@zag-js/navigation-menu" import { useMachine, normalizeProps } from "@zag-js/svelte" const id = $props.id() const service = useMachine(navigationMenu.machine, { id }) const api = $derived(navigationMenu.connect(service, normalizeProps)) </script> <nav {...api.getRootProps()}> <ul {...api.getListProps()}> <!-- Item with dropdown content --> <li {...api.getItemProps({ value: "products" })}> <button {...api.getTriggerProps({ value: "products" })}> Products </button> <div {...api.getContentProps({ value: "products" })}> <a {...api.getLinkProps({ value: "products" })} href="/analytics"> Analytics </a> <a {...api.getLinkProps({ value: "products" })} href="/marketing"> Marketing </a> </div> </li> <!-- Simple link item --> <li {...api.getItemProps({ value: "pricing" })}> <a {...api.getLinkProps({ value: "pricing" })} href="/pricing"> Pricing </a> </li> </ul> </nav>

The basic pattern places content directly within each item. This is suitable for simple dropdown menus where each dropdown appears below its trigger.

Advanced pattern with viewport

The viewport pattern uses a shared viewport container for all content. This enables smooth transitions and better performance for complex navigation layouts.

In this pattern:

  • Content is rendered inside a shared viewport element
  • The viewport automatically positions itself relative to the active trigger
  • You must include triggerProxy and viewportProxy for proper focus management
<script lang="ts"> import * as navigationMenu from "@zag-js/navigation-menu" import { useMachine, normalizeProps } from "@zag-js/svelte" const id = $props.id() const service = useMachine(navigationMenu.machine, { id }) const api = $derived(navigationMenu.connect(service, normalizeProps)) </script> <nav {...api.getRootProps()}> <div {...api.getIndicatorTrackProps()}> <ul {...api.getListProps()}> <!-- Item with trigger --> <li {...api.getItemProps({ value: "products" })}> <button {...api.getTriggerProps({ value: "products" })}> Products </button> <!-- Focus management proxies --> <span {...api.getTriggerProxyProps({ value: "products" })} /> <span {...api.getViewportProxyProps({ value: "products" })} /> </li> <!-- Simple link --> <li {...api.getItemProps({ value: "pricing" })}> <a {...api.getLinkProps({ value: "pricing" })} href="/pricing"> Pricing </a> </li> <!-- Indicator --> <div {...api.getIndicatorProps()}> <div {...api.getArrowProps()} /> </div> </ul> </div> <!-- Shared viewport for all content --> <div {...api.getViewportPositionerProps()}> <div {...api.getViewportProps()}> <!-- Content for products --> <div {...api.getContentProps({ value: "products" })}> <a {...api.getLinkProps({ value: "products" })} href="/analytics"> Analytics </a> <a {...api.getLinkProps({ value: "products" })} href="/marketing"> Marketing </a> </div> </div> </div> </nav>

When to use viewport pattern:

  • Complex navigation with varying content sizes
  • Smooth animated transitions between different content
  • Header navigation bars (like on e-commerce sites)
  • When you want a single shared container for all dropdowns

Controlling the navigation menu

To control which item is currently open, pass the value and onValueChange properties to the machine.

<script lang="ts"> import { writable } from "svelte/store" const value = writable("") const service = useMachine(navigationMenu.machine, { id: "nav", value: $value, onValueChange: (details) => value.set(details.value), }) const api = $derived(navigationMenu.connect(service, normalizeProps)) </script> <div> <button on:click={() => value.set("products")}>Open Products</button> <button on:click={() => value.set("")}>Close All</button> <nav {...api.getRootProps()}> <!-- ... navigation items ... --> </nav> </div>

Listening for value changes

When the open item changes, the onValueChange callback is invoked with the new value.

const service = useMachine(navigationMenu.machine, { id: "nav", onValueChange(details) { // details => { value: string } console.log("Current open item:", details.value) }, })

Adding an animated indicator

To show a visual indicator that animates to the active trigger, render the indicator within the list container:

<nav {...api.getRootProps()}> <div {...api.getListProps()}> <!-- ... items ... --> <div {...api.getIndicatorProps()}> <div {...api.getArrowProps()} /> </div> </div> </nav>

The indicator automatically transitions to match the active trigger's position and size using CSS variables.

Configuring hover delays

You can customize the delay before opening and closing on hover:

const service = useMachine(navigationMenu.machine, { openDelay: 300, // Delay before opening on hover (default: 200ms) closeDelay: 400, // Delay before closing on pointer leave (default: 300ms) })

Tip: Longer delays provide a more forgiving user experience but can feel less responsive.

Disabling hover or click triggers

You can disable hover or click triggers independently:

const service = useMachine(navigationMenu.machine, { disableHoverTrigger: true, // Only open on click // OR disableClickTrigger: true, // Only open on hover // OR disablePointerLeaveClose: true, // Prevents closing when pointer leaves })
  • disableHoverTrigger — Prevents opening on hover (click only)
  • disableClickTrigger — Prevents opening on click (hover only)
  • disablePointerLeaveClose — Prevents closing when pointer leaves

Changing orientation

The default orientation is horizontal. To create a vertical navigation menu:

const service = useMachine(navigationMenu.machine, { orientation: "vertical", })

This affects keyboard navigation (arrow keys) and indicator positioning.

Disabling items

To disable a navigation item, pass disabled: true to the item props:

<div {...api.getItemProps({ value: "products", disabled: true })}> <button {...api.getTriggerProps({ value: "products", disabled: true })}> Products </button> </div>

Disabled items cannot be opened and are skipped during keyboard navigation.

Indicating current page

To highlight the current page link, use the current prop:

<a {...api.getLinkProps({ value: "products", current: true })}>Products</a>

This adds data-current attribute and aria-current="page" for accessibility.

RTL support

The navigation menu supports right-to-left languages. Set the dir property to rtl:

const service = useMachine(navigationMenu.machine, { dir: "rtl", })

Styling guide

Earlier, we mentioned that each navigation menu part has a data-part attribute added to them to select and style them in the DOM.

Open and closed states

When content is open or closed, it receives a data-state attribute:

[data-part="content"][data-state="open|closed"] { /* Styles for open or closed content */ } [data-part="trigger"][data-state="open|closed"] { /* Styles for open or closed trigger */ } [data-part="viewport"][data-state="open|closed"] { /* Styles for viewport open/closed state */ }

Selected item state

When an item is selected (open), it receives data-state="open":

[data-part="item"][data-state="open"] { /* Styles for open item */ }

Disabled state

Disabled items have a data-disabled attribute:

[data-part="item"][data-disabled] { /* Styles for disabled items */ } [data-part="trigger"][data-disabled] { /* Styles for disabled triggers */ }

Orientation styles

All parts have a data-orientation attribute:

[data-part="root"][data-orientation="horizontal|vertical"] { /* Orientation-specific styles */ } [data-part="list"][data-orientation="horizontal"] { display: flex; flex-direction: row; } [data-part="list"][data-orientation="vertical"] { display: flex; flex-direction: column; }

Links marked as current have a data-current attribute:

[data-part="link"][data-current] { /* Styles for current page link */ }

Styling the indicator

The indicator uses CSS variables for positioning and sizing:

[data-part="indicator"] { position: absolute; transition: translate 250ms ease, width 250ms ease, height 250ms ease; } [data-part="indicator"][data-orientation="horizontal"] { left: 0; translate: var(--trigger-x) 0; width: var(--trigger-width); } [data-part="indicator"][data-orientation="vertical"] { top: 0; translate: 0 var(--trigger-y); height: var(--trigger-height); }

Styling the viewport

The viewport uses CSS variables for positioning and sizing:

[data-part="viewport"] { position: absolute; width: var(--viewport-width); height: var(--viewport-height); transition: width 300ms ease, height 300ms ease; }

Arrow styling

The arrow can be styled using CSS variables:

[data-part="root"] { --arrow-size: 20px; } [data-part="arrow"] { width: var(--arrow-size); height: var(--arrow-size); background: white; rotate: 45deg; }

Motion attributes

When using the viewport pattern, content elements receive data-motion attributes for directional animations:

[data-part="content"][data-motion="from-start"] { animation: slideFromStart 250ms ease; } [data-part="content"][data-motion="from-end"] { animation: slideFromEnd 250ms ease; } [data-part="content"][data-motion="to-start"] { animation: slideToStart 250ms ease; } [data-part="content"][data-motion="to-end"] { animation: slideToEnd 250ms ease; }

Tip: The motion direction indicates where the content is coming from (from-start/from-end) or going to (to-start/to-end), enabling context-aware animations when switching between items.

Methods and Properties

Machine Context

The navigation menu machine exposes the following context properties:

  • idsPartial<{ root: string; list: string; item: string; trigger: (value: string) => string; content: (value: string) => string; viewport: string; }>The ids of the elements in the machine.
  • valuestringThe controlled value of the navigation menu
  • defaultValuestringThe default value of the navigation menu. Use when you don't want to control the value of the menu.
  • onValueChange(details: ValueChangeDetails) => voidFunction called when the value of the menu changes
  • openDelaynumberThe delay before the menu opens
  • closeDelaynumberThe delay before the menu closes
  • disableClickTriggerbooleanWhether to disable the click trigger
  • disableHoverTriggerbooleanWhether to disable the hover trigger
  • disablePointerLeaveClosebooleanWhether to disable the pointer leave close
  • dir"ltr" | "rtl"The document's text/writing direction.
  • idstringThe unique identifier of the machine.
  • getRootNode() => ShadowRoot | Node | DocumentA root node to correctly resolve document in custom environments. E.x.: Iframes, Electron.
  • orientation"horizontal" | "vertical"The orientation of the element.

Machine API

The navigation menu api exposes the following methods:

  • valuestringThe current value of the menu
  • setValue(value: string) => voidSets the value of the menu
  • openbooleanWhether the menu is open
  • isViewportRenderedbooleanWhether the viewport is rendered
  • getViewportNode() => HTMLElementGets the viewport node element
  • orientationOrientationThe orientation of the menu
  • repositionVoidFunctionFunction to reposition the viewport

Data Attributes

Root
data-scope
navigation-menu
data-part
root
data-orientation
The orientation of the navigation-menu
List
data-scope
navigation-menu
data-part
list
data-orientation
The orientation of the list
Item
data-scope
navigation-menu
data-part
item
data-value
The value of the item
data-state
"open" | "closed"
data-orientation
The orientation of the item
data-disabled
Present when disabled
Indicator
data-scope
navigation-menu
data-part
indicator
data-state
"open" | "closed"
data-orientation
The orientation of the indicator
Arrow
data-scope
navigation-menu
data-part
arrow
data-orientation
The orientation of the arrow
Trigger
data-scope
navigation-menu
data-part
trigger
data-value
The value of the item
data-state
"open" | "closed"
data-disabled
Present when disabled
TriggerProxy
data-scope
navigation-menu
data-part
trigger-proxy
Link
data-scope
navigation-menu
data-part
link
data-value
The value of the item
data-current
Present when current
Content
data-scope
navigation-menu
data-part
content
data-state
"open" | "closed"
data-orientation
The orientation of the content
data-value
The value of the item
ViewportPositioner
data-scope
navigation-menu
data-part
viewport-positioner
data-orientation
The orientation of the viewportpositioner
Viewport
data-scope
navigation-menu
data-part
viewport
data-state
"open" | "closed"
data-orientation
The orientation of the viewport
ItemIndicator
data-scope
navigation-menu
data-part
item-indicator
data-state
"open" | "closed"
data-orientation
The orientation of the item
data-value
The value of the item

CSS Variables

Root
--trigger-width
The width of the Root
--trigger-height
The height of the Root
--trigger-x
The trigger x value for the Root
--trigger-y
The trigger y value for the Root
--viewport-width
The width of the Root
--viewport-height
The height of the Root
--viewport-x
The viewport x value for the Root
--viewport-y
The viewport y value for the Root
Viewport
--viewport-width
The width of the Viewport
--viewport-height
The height of the Viewport
--viewport-x
The viewport x value for the Viewport
--viewport-y
The viewport y value for the Viewport
Content
--layer-index
The index of the dismissable in the layer stack
--nested-layer-count
The number of nested navigation-menus
Backdrop
--layer-index
The index of the dismissable in the layer stack

Accessibility

Keyboard Interactions

  • ArrowDown
    When focus is on trigger (vertical orientation), moves focus to the next trigger.
  • ArrowUp
    When focus is on trigger (vertical orientation), moves focus to the previous trigger.
  • ArrowRight
    When focus is on trigger (horizontal orientation), moves focus to the next trigger.
    When focus is on content, moves focus to the next link.
  • ArrowLeft
    When focus is on trigger (horizontal orientation), moves focus to the previous trigger.
    When focus is on content, moves focus to the previous link.
  • Home
    When focus is on trigger, moves focus to the first trigger.
    When focus is on content, moves focus to the first link.
  • End
    When focus is on trigger, moves focus to the last trigger.
    When focus is on content, moves focus to the last link.
Edit this page on GitHub