Skip to content
data-slotv1.0.0
Esc
↑↓navigate↵open⌘Jpreview
On this page

Navigation Menu

Site navigation with coordinated popups, an indicator, and a shared viewport.

@data-slot/navigation-menuSource ↗
bun add @data-slot/navigation-menu
npm install @data-slot/navigation-menu
pnpm add @data-slot/navigation-menu

Anatomy

Top-level items live in the list. The portal, positioner, and popup wrappers are optional: author just a navigation-menu-viewport and the runtime creates the rest while the menu is open. The indicator is optional too.

<nav data-slot="navigation-menu">
  <ul data-slot="navigation-menu-list">
    <li data-slot="navigation-menu-item" data-value="unique-id">
      <button data-slot="navigation-menu-trigger">Label</button>
      <div data-slot="navigation-menu-content">
        <!-- Links, content -->
      </div>
    </li>
    <!-- Optional hover indicator -->
    <div data-slot="navigation-menu-indicator"></div>
  </ul>
  <div data-slot="navigation-menu-portal">
    <div data-slot="navigation-menu-positioner">
      <div data-slot="navigation-menu-popup">
        <div data-slot="navigation-menu-viewport"></div>
      </div>
    </div>
  </div>
</nav>

Examples

Preview
Show codeHide code
<!-- Add data-position-method="fixed" for sticky headers -->
<nav aria-label="Shared viewport navigation" data-slot="navigation-menu" class="nav-menu" data-side-offset="8">
  <ul data-slot="navigation-menu-list" class="nav-menu-list">
    <li data-slot="navigation-menu-item" data-value="products" class="nav-menu-item">
      <button data-slot="navigation-menu-trigger" class="nav-menu-trigger">Products</button>
      <div data-slot="navigation-menu-content" class="nav-menu-content" hidden>
        <div class="nav-menu-grid">
          <a href="#" class="nav-menu-link">
            <div class="nav-menu-link-title">Analytics</div>
            <div class="nav-menu-link-desc">Real-time metrics</div>
          </a>
          <!-- More links... -->
        </div>
      </div>
    </li>
    <li data-slot="navigation-menu-item" class="nav-menu-item">
      <a href="#" class="nav-menu-trigger nav-menu-trigger-plain">Docs</a>
    </li>
    <!-- More submenu items... -->
    <li role="presentation" aria-hidden="true" data-slot="navigation-menu-indicator" class="nav-menu-indicator"></li>
  </ul>
  <div data-slot="navigation-menu-portal">
    <div data-slot="navigation-menu-positioner" class="nav-menu-positioner">
      <div data-slot="navigation-menu-popup" class="nav-menu-popup" hidden>
        <div data-slot="navigation-menu-viewport" class="nav-menu-viewport" hidden></div>
      </div>
    </div>
  </div>
</nav>

<style>
  .nav-menu { position: relative; }
  .nav-menu-list { display: flex; flex-wrap: wrap; list-style: none; position: relative; }
  .nav-menu-trigger {
    padding: 0.625rem 1rem;
    background: none;
    border: none;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 0.375rem;
    text-decoration: none;
    color: inherit;
  }
  .nav-menu-trigger::after { content: "▼"; font-size: 0.75rem;
    line-height: 1rem;
  }
  .nav-menu-trigger[data-state="open"]::after { transform: rotate(180deg); }
  .nav-menu-trigger-plain::after { content: none; }

  .nav-menu-positioner {
    box-sizing: border-box;
    width: var(--positioner-width);
    height: var(--positioner-height);
    max-width: var(--available-width);
    top: 0;
    left: 0;
    transition: top 0.22s cubic-bezier(0.32, 0.72, 0, 1),
                left 0.22s cubic-bezier(0.32, 0.72, 0, 1);
  }
  .nav-menu-positioner[data-instant] { transition: none; }

  .nav-menu-popup {
    position: relative;
    box-sizing: border-box;
    width: var(--popup-width);
    height: var(--popup-height); max-height: var(--available-height);
    transform-origin: var(--transform-origin);
    background: var(--bg);
    box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
    overflow: hidden;
    transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1),
                width 0.16s ease,
                height 0.16s ease,
                opacity 0.16s ease;
    border: 1px solid var(--border);
  }
  .nav-menu-popup[data-instant] {
    transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1),
                opacity 0.16s ease;
  }
  .nav-menu-popup[data-starting-style],
  .nav-menu-popup[data-ending-style] {
    opacity: 0;
    transform: scale(0.96);
  }
  .nav-menu-popup[data-closed]:not([data-ending-style]) {
    pointer-events: none;
  }

  .nav-menu-viewport { position: relative; overflow: hidden; width: 100%; height: 100%; }
  /* Use the target popup width inside its 1px border, independent of the shell
     animation. Only scroll panels when the window constrains their size. */
  .nav-menu-content {
    position: relative;
    width: calc(var(--popup-width) - 2px);
    max-width: calc(var(--available-width) - 2px);
    max-height: calc(var(--available-height) - 2px);
    overflow: auto;
    transform: translateX(0);
    transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1),
                opacity 0.16s ease;
    will-change: transform, opacity;
  }
  .nav-menu-content[data-closed] { overflow: hidden; }
  .nav-menu-content[data-closed]:not([data-ending-style]) {
    opacity: 0;
    pointer-events: none;
  }
  .nav-menu-content[data-starting-style],
  .nav-menu-content[data-ending-style] { opacity: 0; }
  .nav-menu-content[data-starting-style][data-activation-direction="right"] {
    transform: translateX(24px);
  }
  .nav-menu-content[data-starting-style][data-activation-direction="left"] {
    transform: translateX(-24px);
  }
  .nav-menu-content[data-ending-style][data-activation-direction="right"] {
    transform: translateX(-24px);
  }
  .nav-menu-content[data-ending-style][data-activation-direction="left"] {
    transform: translateX(24px);
  }
  .nav-menu-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; width: 24rem; max-width: 100%; padding: 0.75rem; }
  @media (width < 640px) { .nav-menu-grid { grid-template-columns: 1fr;
    width: 24rem;
  } }
</style>
<!-- Add data-position-method="fixed" for sticky headers -->
<nav aria-label="Shared viewport navigation" data-slot="navigation-menu" class="relative" style="--navigation-chevron: '▼'" data-side-offset="8">
  <ul data-slot="navigation-menu-list" class="flex flex-wrap relative list-none">
    <li data-slot="navigation-menu-item" data-value="products" class="static">
      <button
        data-slot="navigation-menu-trigger"
        class="px-4 py-2.5 bg-transparent border-none cursor-pointer
               flex items-center gap-1.5 transition-colors relative z-1
               hover:text-[var(--accent)] data-[state=open]:text-[var(--accent)]
               after:content-(--navigation-chevron) after:text-xs
               after:transition-transform after:duration-200
               data-[state=open]:after:rotate-180"
      >
        Products
      </button>
      <div
        data-slot="navigation-menu-content"
        class="nav-menu-tw-content z-10"
        hidden
      >
        <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 w-96 max-w-full p-3">
          <a href="#" class="block p-3 transition-colors hover:bg-code-bg text-inherit">
            <div class="mb-1 font-medium">Analytics</div>
            <div class="text-sm text-[var(--muted)]">Real-time metrics</div>
          </a>
          <!-- More links... -->
        </div>
      </div>
    </li>
    <li data-slot="navigation-menu-item" class="static">
      <a
        href="#"
        class="px-4 py-2.5 bg-transparent border-none cursor-pointer
               flex items-center gap-1.5 transition-colors relative z-1
               hover:text-[var(--accent)] text-inherit no-underline"
      >
        Docs
      </a>
    </li>
    <!-- More submenu items... -->
    <li role="presentation" aria-hidden="true"
      data-slot="navigation-menu-indicator"
      class="absolute bg-code-bg z-0 transition-all duration-150
             pointer-events-none opacity-0 data-[state=visible]:opacity-100"
      style="left: var(--indicator-left, 0); top: var(--indicator-top, 0);
             width: var(--indicator-width, 0); height: var(--indicator-height, 0);"
    ></li>
  </ul>
  <div data-slot="navigation-menu-portal">
    <div data-slot="navigation-menu-positioner" class="nav-menu-tw-positioner z-50">
      <div
        data-slot="navigation-menu-popup"
        class="nav-menu-tw-popup relative overflow-hidden border border-[var(--border)] bg-bg shadow-lg"
        hidden
        style="width: var(--popup-width); height: var(--popup-height); max-height: var(--available-height); transform-origin: var(--transform-origin);"
      >
        <div data-slot="navigation-menu-viewport" class="relative h-full w-full overflow-hidden" hidden></div>
      </div>
    </div>
  </div>
</nav>

/* Because the popup stack is portaled, target the moving parts directly. */
.nav-menu-tw-positioner {
  box-sizing: border-box;
  width: var(--positioner-width);
  height: var(--positioner-height);
  max-width: var(--available-width);
  transition: top 0.22s cubic-bezier(0.32, 0.72, 0, 1),
              left 0.22s cubic-bezier(0.32, 0.72, 0, 1);
}
.nav-menu-tw-positioner[data-instant] {
  transition: none;
}
.nav-menu-tw-popup {
  position: relative;
  box-sizing: border-box;
  transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1),
              width 0.16s ease,
              height 0.16s ease,
              opacity 0.16s ease;
  will-change: transform, width, height;
}
.nav-menu-tw-popup[data-instant] {
  transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1),
              opacity 0.16s ease;
}
.nav-menu-tw-popup[data-starting-style],
.nav-menu-tw-popup[data-ending-style] {
  opacity: 0;
  transform: scale(0.96);
}
.nav-menu-tw-popup[data-closed]:not([data-ending-style]) {
  pointer-events: none;
}
.nav-menu-tw-content[data-closed] { overflow: hidden; }
.nav-menu-tw-content[data-closed]:not([data-ending-style]) {
  opacity: 0;
  pointer-events: none;
}
/* Use the target popup width inside its 1px border, independent of the shell
   animation. Only scroll panels when the window constrains their size. */
.nav-menu-tw-content {
  position: relative;
  width: calc(var(--popup-width) - 2px);
  max-width: calc(var(--available-width) - 2px);
  max-height: calc(var(--available-height) - 2px);
  overflow: auto;
  transform: translateX(0);
  transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1),
              opacity 0.16s ease;
  will-change: transform, opacity;
}
.nav-menu-tw-content[data-starting-style],
.nav-menu-tw-content[data-ending-style] {
  opacity: 0;
}
.nav-menu-tw-content[data-starting-style][data-activation-direction="right"] {
  transform: translateX(24px);
}
.nav-menu-tw-content[data-starting-style][data-activation-direction="left"] {
  transform: translateX(-24px);
}
.nav-menu-tw-content[data-ending-style][data-activation-direction="right"] {
  transform: translateX(-24px);
}
.nav-menu-tw-content[data-ending-style][data-activation-direction="left"] {
  transform: translateX(24px);
}

Fixed positioning

data-position-method="fixed" keeps popups aligned when the navigation sits in a sticky or fixed header.

Preview
Show codeHide code
<!-- Add data-position-method="fixed" for sticky headers -->
<nav aria-label="Fixed positioning navigation" data-slot="navigation-menu" data-position-method="fixed" class="nav-menu" data-side-offset="8">
  <ul data-slot="navigation-menu-list" class="nav-menu-list">
    <li data-slot="navigation-menu-item" data-value="products" class="nav-menu-item">
      <button data-slot="navigation-menu-trigger" class="nav-menu-trigger">Products</button>
      <div data-slot="navigation-menu-content" class="nav-menu-content" hidden>
        <div class="nav-menu-grid">
          <a href="#" class="nav-menu-link">
            <div class="nav-menu-link-title">Analytics</div>
            <div class="nav-menu-link-desc">Real-time metrics</div>
          </a>
          <!-- More links... -->
        </div>
      </div>
    </li>
    <li data-slot="navigation-menu-item" class="nav-menu-item">
      <a href="#" class="nav-menu-trigger nav-menu-trigger-plain">Docs</a>
    </li>
    <!-- More submenu items... -->
    <li role="presentation" aria-hidden="true" data-slot="navigation-menu-indicator" class="nav-menu-indicator"></li>
  </ul>
  <div data-slot="navigation-menu-portal">
    <div data-slot="navigation-menu-positioner" class="nav-menu-positioner">
      <div data-slot="navigation-menu-popup" class="nav-menu-popup" hidden>
        <div data-slot="navigation-menu-viewport" class="nav-menu-viewport" hidden></div>
      </div>
    </div>
  </div>
</nav>

<style>
  .nav-menu { position: relative; }
  .nav-menu-list { display: flex; flex-wrap: wrap; list-style: none; position: relative; }
  .nav-menu-trigger {
    padding: 0.625rem 1rem;
    background: none;
    border: none;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 0.375rem;
    text-decoration: none;
    color: inherit;
  }
  .nav-menu-trigger::after { content: "▼"; font-size: 0.75rem;
    line-height: 1rem;
  }
  .nav-menu-trigger[data-state="open"]::after { transform: rotate(180deg); }
  .nav-menu-trigger-plain::after { content: none; }

  .nav-menu-positioner {
    box-sizing: border-box;
    width: var(--positioner-width);
    height: var(--positioner-height);
    max-width: var(--available-width);
    top: 0;
    left: 0;
    transition: top 0.22s cubic-bezier(0.32, 0.72, 0, 1),
                left 0.22s cubic-bezier(0.32, 0.72, 0, 1);
  }
  .nav-menu-positioner[data-instant] { transition: none; }

  .nav-menu-popup {
    position: relative;
    box-sizing: border-box;
    width: var(--popup-width);
    height: var(--popup-height); max-height: var(--available-height);
    transform-origin: var(--transform-origin);
    background: var(--bg);
    box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
    overflow: hidden;
    transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1),
                width 0.16s ease,
                height 0.16s ease,
                opacity 0.16s ease;
    border: 1px solid var(--border);
  }
  .nav-menu-popup[data-instant] {
    transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1),
                opacity 0.16s ease;
  }
  .nav-menu-popup[data-starting-style],
  .nav-menu-popup[data-ending-style] {
    opacity: 0;
    transform: scale(0.96);
  }
  .nav-menu-popup[data-closed]:not([data-ending-style]) {
    pointer-events: none;
  }

  .nav-menu-viewport { position: relative; overflow: hidden; width: 100%; height: 100%; }
  /* Use the target popup width inside its 1px border, independent of the shell
     animation. Only scroll panels when the window constrains their size. */
  .nav-menu-content {
    position: relative;
    width: calc(var(--popup-width) - 2px);
    max-width: calc(var(--available-width) - 2px);
    max-height: calc(var(--available-height) - 2px);
    overflow: auto;
    transform: translateX(0);
    transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1),
                opacity 0.16s ease;
    will-change: transform, opacity;
  }
  .nav-menu-content[data-closed] { overflow: hidden; }
  .nav-menu-content[data-closed]:not([data-ending-style]) {
    opacity: 0;
    pointer-events: none;
  }
  .nav-menu-content[data-starting-style],
  .nav-menu-content[data-ending-style] { opacity: 0; }
  .nav-menu-content[data-starting-style][data-activation-direction="right"] {
    transform: translateX(24px);
  }
  .nav-menu-content[data-starting-style][data-activation-direction="left"] {
    transform: translateX(-24px);
  }
  .nav-menu-content[data-ending-style][data-activation-direction="right"] {
    transform: translateX(-24px);
  }
  .nav-menu-content[data-ending-style][data-activation-direction="left"] {
    transform: translateX(24px);
  }
  .nav-menu-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; width: 24rem; max-width: 100%; padding: 0.75rem; }
  @media (width < 640px) { .nav-menu-grid { grid-template-columns: 1fr;
    width: 24rem;
  } }
</style>
<!-- Add data-position-method="fixed" for sticky headers -->
<nav aria-label="Fixed positioning navigation" data-slot="navigation-menu" data-position-method="fixed" class="relative" style="--navigation-chevron: '▼'" data-side-offset="8">
  <ul data-slot="navigation-menu-list" class="flex flex-wrap relative list-none">
    <li data-slot="navigation-menu-item" data-value="products" class="static">
      <button
        data-slot="navigation-menu-trigger"
        class="px-4 py-2.5 bg-transparent border-none cursor-pointer
               flex items-center gap-1.5 transition-colors relative z-1
               hover:text-[var(--accent)] data-[state=open]:text-[var(--accent)]
               after:content-(--navigation-chevron) after:text-xs
               after:transition-transform after:duration-200
               data-[state=open]:after:rotate-180"
      >
        Products
      </button>
      <div
        data-slot="navigation-menu-content"
        class="nav-menu-tw-content z-10"
        hidden
      >
        <div class="grid grid-cols-1 sm:grid-cols-2 gap-3 w-96 max-w-full p-3">
          <a href="#" class="block p-3 transition-colors hover:bg-code-bg text-inherit">
            <div class="mb-1 font-medium">Analytics</div>
            <div class="text-sm text-[var(--muted)]">Real-time metrics</div>
          </a>
          <!-- More links... -->
        </div>
      </div>
    </li>
    <li data-slot="navigation-menu-item" class="static">
      <a
        href="#"
        class="px-4 py-2.5 bg-transparent border-none cursor-pointer
               flex items-center gap-1.5 transition-colors relative z-1
               hover:text-[var(--accent)] text-inherit no-underline"
      >
        Docs
      </a>
    </li>
    <!-- More submenu items... -->
    <li role="presentation" aria-hidden="true"
      data-slot="navigation-menu-indicator"
      class="absolute bg-code-bg z-0 transition-all duration-150
             pointer-events-none opacity-0 data-[state=visible]:opacity-100"
      style="left: var(--indicator-left, 0); top: var(--indicator-top, 0);
             width: var(--indicator-width, 0); height: var(--indicator-height, 0);"
    ></li>
  </ul>
  <div data-slot="navigation-menu-portal">
    <div data-slot="navigation-menu-positioner" class="nav-menu-tw-positioner z-50">
      <div
        data-slot="navigation-menu-popup"
        class="nav-menu-tw-popup relative overflow-hidden border border-[var(--border)] bg-bg shadow-lg"
        hidden
        style="width: var(--popup-width); height: var(--popup-height); max-height: var(--available-height); transform-origin: var(--transform-origin);"
      >
        <div data-slot="navigation-menu-viewport" class="relative h-full w-full overflow-hidden" hidden></div>
      </div>
    </div>
  </div>
</nav>

/* Because the popup stack is portaled, target the moving parts directly. */
.nav-menu-tw-positioner {
  box-sizing: border-box;
  width: var(--positioner-width);
  height: var(--positioner-height);
  max-width: var(--available-width);
  transition: top 0.22s cubic-bezier(0.32, 0.72, 0, 1),
              left 0.22s cubic-bezier(0.32, 0.72, 0, 1);
}
.nav-menu-tw-positioner[data-instant] {
  transition: none;
}
.nav-menu-tw-popup {
  position: relative;
  box-sizing: border-box;
  transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1),
              width 0.16s ease,
              height 0.16s ease,
              opacity 0.16s ease;
  will-change: transform, width, height;
}
.nav-menu-tw-popup[data-instant] {
  transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1),
              opacity 0.16s ease;
}
.nav-menu-tw-popup[data-starting-style],
.nav-menu-tw-popup[data-ending-style] {
  opacity: 0;
  transform: scale(0.96);
}
.nav-menu-tw-popup[data-closed]:not([data-ending-style]) {
  pointer-events: none;
}
.nav-menu-tw-content[data-closed] { overflow: hidden; }
.nav-menu-tw-content[data-closed]:not([data-ending-style]) {
  opacity: 0;
  pointer-events: none;
}
/* Use the target popup width inside its 1px border, independent of the shell
   animation. Only scroll panels when the window constrains their size. */
.nav-menu-tw-content {
  position: relative;
  width: calc(var(--popup-width) - 2px);
  max-width: calc(var(--available-width) - 2px);
  max-height: calc(var(--available-height) - 2px);
  overflow: auto;
  transform: translateX(0);
  transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1),
              opacity 0.16s ease;
  will-change: transform, opacity;
}
.nav-menu-tw-content[data-starting-style],
.nav-menu-tw-content[data-ending-style] {
  opacity: 0;
}
.nav-menu-tw-content[data-starting-style][data-activation-direction="right"] {
  transform: translateX(24px);
}
.nav-menu-tw-content[data-starting-style][data-activation-direction="left"] {
  transform: translateX(-24px);
}
.nav-menu-tw-content[data-ending-style][data-activation-direction="right"] {
  transform: translateX(-24px);
}
.nav-menu-tw-content[data-ending-style][data-activation-direction="left"] {
  transform: translateX(24px);
}

API reference

Initialization

create(scope?)

Auto-discover and bind all navigation menu instances in a scope (defaults to document).

import { create } from "@data-slot/navigation-menu";

const controllers = create(); // Returns NavigationMenuController[]

createNavigationMenu(root, options?)

Create a controller for a specific element.

import { createNavigationMenu } from "@data-slot/navigation-menu";

const menu = createNavigationMenu(element, {
  delayOpen: 0,
  delayClose: 0,
  onValueChange: (value) => console.log(value),
});

Slots

Runtime Slots

  • navigation-menu - Root navigation element that manages the active panel and open state.
  • navigation-menu-list - Required top-level list containing navigation items; used to discover top-level hover and focus targets.
  • navigation-menu-item - Container for a navigation entry; at least one item is required. Use data-value to identify an entry with a content panel.
  • navigation-menu-trigger - Button inside an item that opens its associated content panel.
  • navigation-menu-content - Content panel inside an item, displayed when that item is active.
  • navigation-menu-indicator - Optional animated highlight that follows top-level hover/focus targets and stays anchored to the active trigger while a panel is open.
  • navigation-menu-portal - Portal wrapper moved to document.body while the menu is open.
  • navigation-menu-positioner - Popup positioning wrapper that receives resolved side/alignment attributes and sizing variables.
  • navigation-menu-popup - Animated popup shell around the viewport.
  • navigation-menu-viewport - Optional clipping viewport that holds the active content panel.
  • navigation-menu-viewport-positioner - Deprecated alias for navigation-menu-positioner.

Generated Slots

  • navigation-menu-bridge - Runtime-created hover safety shield that keeps the menu open while crossing the gap to its content.
  • navigation-menu-safe-triangle - Runtime-created debug polygon that visualizes the hover safety corridor when debugging is enabled.

Markup

<nav data-slot="navigation-menu">
  <ul data-slot="navigation-menu-list">
    <li data-slot="navigation-menu-item" data-value="unique-id">
      <button data-slot="navigation-menu-trigger">Label</button>
      <div data-slot="navigation-menu-content">
        <!-- Links, content -->
      </div>
    </li>
    <!-- Optional hover indicator -->
    <li data-slot="navigation-menu-indicator" role="presentation" aria-hidden="true"></li>
  </ul>
  <div data-slot="navigation-menu-portal">
    <div data-slot="navigation-menu-positioner">
      <div data-slot="navigation-menu-popup">
        <div data-slot="navigation-menu-viewport"></div>
      </div>
    </div>
  </div>
</nav>

If you only author navigation-menu-viewport, the runtime synthesizes the missing navigation-menu-portal, navigation-menu-positioner, and navigation-menu-popup wrappers while the menu is open.

Data Attributes

Options can also be set via data attributes on the root element. JS options take precedence.

Attribute Type Default Description
data-delay-open number 0 Delay before opening on hover (ms)
data-delay-close number 0 Delay before closing on mouse leave (ms)
data-open-on-focus boolean false Whether focusing a trigger opens its content
data-mount-strategy "lazy" | "eager" "lazy" Detach inactive panels or keep them connected and hidden
data-side string "bottom" Side: "top", "right", "bottom", "left"
data-align string "start" Viewport alignment: "start", "center", or "end"
data-side-offset number 0 Distance from trigger to viewport (px)
data-align-offset number 0 Cross-axis alignment offset (px)
data-position-method string "absolute" Positioning strategy: "absolute" or "fixed"
data-safe-triangle boolean false Enable hover safe-triangle switching guard
data-debug-safe-triangle boolean false Show red hover safe-triangle debug overlay

Boolean attributes: present or "true" = true, "false" = false, absent = default.

Placement attributes (data-side, data-align, data-side-offset, data-align-offset) control how the viewport is positioned relative to the active trigger:

  • start - Align viewport left edge with trigger left edge (default)
  • center - Center viewport under trigger
  • end - Align viewport right edge with trigger right edge

Can be set on:

  1. navigation-menu-content (highest priority, per-panel)
  2. navigation-menu-item
  3. navigation-menu root (lowest priority, global default)

navigation-menu-positioner is the canonical popup positioning slot. Its data-side / data-align are mirrored output values and are not used as placement inputs. navigation-menu-viewport-positioner is still accepted as a legacy alias.

<!-- Faster hover response with focus auto-open opt-in -->
<nav data-slot="navigation-menu" data-delay-open="100" data-open-on-focus="true">
  ...
</nav>

<!-- Center-align a narrow submenu under its trigger -->
<li data-slot="navigation-menu-item" data-value="company" data-align="center">
  <button data-slot="navigation-menu-trigger">Company</button>
  <div data-slot="navigation-menu-content">
    <a href="/about">About</a>
    <a href="/careers">Careers</a>
  </div>
</li>

Output Attributes

Surface Attributes
root data-state="open|closed", data-open, data-closed
positioner data-state="open|closed", data-open, data-closed, data-side, data-align, data-instant
popup data-state="open|closed", data-open, data-closed, data-side, data-align, data-starting-style, data-ending-style, data-instant
viewport data-state="open|closed", data-open, data-closed, data-side, data-align, data-starting-style, data-ending-style, data-instant
content data-state="active|inactive", data-open, data-closed, data-side, data-align, data-starting-style, data-ending-style, data-activation-direction="left|right"

data-activation-direction is only emitted while switching between open top-level panels. A full close is intentionally non-directional.

Options

Option Type Default Description
delayOpen number 0 Delay before opening on hover (ms)
delayClose number 0 Delay before closing on mouse leave (ms)
openOnFocus boolean false Whether focusing a trigger opens its content
mountStrategy "lazy" | "eager" "lazy" Detach inactive panels or keep them connected and hidden
side "top" | "right" | "bottom" | "left" "bottom" Viewport side relative to trigger
align "start" | "center" | "end" "start" Viewport alignment on cross-axis
sideOffset number 0 Distance from trigger to viewport (px)
alignOffset number 0 Cross-axis alignment offset (px)
positionMethod "absolute" | "fixed" "absolute" Positioning strategy for the shared popup positioner
safeTriangle boolean false Enable hover safe-triangle switching guard
onValueChange (value: string | null) => void undefined Callback when active item changes
debugSafeTriangle boolean false Show red hover safe-triangle debug overlay

Content mounting and server rendering

By default, Navigation Menu removes its closed panels from the page’s DOM. This keeps pages with large menus smaller after initialization. The menu buttons and links outside those panels stay in the page.

Opening a panel puts the same content back into the page. Its state and event listeners are preserved. When switching panels, the new panel appears while the previous one finishes its exit animation. After that animation, the previous panel is removed.

Closing the menu waits for the panels and their surrounding popup to finish their exit animations. Reopening a panel during its exit animation keeps it in the page.

Most applications can use this default without any changes.

If your code needs to find or update links inside a closed panel—for example, using querySelector—use eager mode to keep the panels in the DOM:

createNavigationMenu(root, { mountStrategy: "eager" });

You can also add data-mount-strategy="eager" to the Navigation Menu root. A JavaScript option takes precedence over the HTML attribute.

With the default lazy mode, DOM queries won’t find closed panels or their links. You can also keep a reference to a panel before initializing Navigation Menu and use that reference later.

Server rendering and pages without JavaScript

Lazy mounting does not reduce the HTML sent to the browser. Panel content is still included in your HTML; Navigation Menu removes closed panels from the DOM after JavaScript initializes.

Make sure essential navigation works before JavaScript loads or if it never runs. You can leave the links visible until initialization, or provide separate fallback navigation if your panels start hidden. Neither lazy nor eager mode provides that fallback automatically.

Controller

Method/Property Description
open(value) Open a specific item
close() Close the menu
value Currently active item value (readonly string | null)
destroy() Cleanup all event listeners

Controller Destruction

destroy() permanently disposes the controller and hides any open surface without emitting an additional change event. Repeated destruction is safe; methods on the old controller become no-ops. Create a new controller on the same root to rebind it.

Events

Outbound Events

Listen for changes via custom events:

element.addEventListener("navigation-menu:change", (e) => {
  console.log("Active item:", e.detail.value);
});

Inbound Events

Control the navigation menu via events:

Event Detail Description
navigation-menu:set { value: string | null } Set active item or close menu
// Open a specific item
element.dispatchEvent(
  new CustomEvent("navigation-menu:set", { detail: { value: "products" } })
);

// Close the menu
element.dispatchEvent(
  new CustomEvent("navigation-menu:set", { detail: { value: null } })
);

Styling

navigation-menu-portal is moved to document.body while open. If authored popup-stack slots are present, they are reused. Otherwise, missing portal / positioner / popup wrappers are generated while open and removed on close.

Runtime geometry is owned by navigation-menu-positioner: position, top, left, width, and height are written inline and reset on close. navigation-menu-popup is the canonical animated shell. The active navigation-menu-content panel is mounted inside navigation-menu-viewport while open. Inactive panels are detached after their exit animation by default; eager panels return to their original markup location.

Use positionMethod: "fixed" or data-position-method="fixed" when the menu needs viewport-based anchoring, such as inside sticky headers.

Basic Styling

/* Hidden by default */
[data-slot="navigation-menu-content"] {
  display: none;
}

[data-slot="navigation-menu-content"][data-open] {
  display: block;
}

/* Positioner owns placement geometry */
[data-slot="navigation-menu-positioner"],
[data-slot="navigation-menu-viewport-positioner"] {
  top: 0;
  left: 0;
  transition: top 0.3s, left 0.3s;
}

/* Skip initial position animation */
[data-slot="navigation-menu-positioner"][data-instant],
[data-slot="navigation-menu-viewport-positioner"][data-instant] {
  transition: none;
}

/* Popup owns the main shell animation */
[data-slot="navigation-menu-popup"] {
  transform-origin: var(--transform-origin);
  width: var(--popup-width);
  height: var(--popup-height);
  transition: transform 0.3s, width 0.3s, height 0.3s, opacity 0.15s;
}

[data-slot="navigation-menu-popup"][data-starting-style],
[data-slot="navigation-menu-popup"][data-ending-style] {
  opacity: 0;
}

/* Viewport keeps backward-compatible sizing aliases */
[data-slot="navigation-menu-viewport"] {
  width: var(--viewport-width);
  height: var(--viewport-height);
}

/* Skip animation on initial open */
[data-slot="navigation-menu-viewport"][data-instant] {
  transition: none;
}

/* Indicator positioning */
[data-slot="navigation-menu-indicator"] {
  position: absolute;
  left: var(--indicator-left);
  width: var(--indicator-width);
  transition: left 0.2s, width 0.2s;
}

data-instant is used for the initial open phase and for sync-driven anchor tracking updates (for example when a sticky trigger moves while the menu is open). It is cleared when switching, closing, or after the tracking update settles. Use it to skip those reposition transitions without suppressing exit animations.

Motion Animations

Use data-starting-style / data-ending-style on navigation-menu-popup for full popup open/close animations, and on navigation-menu-content for panel presence. Directional panel switching should key off data-activation-direction="left|right" on content. The active panel stays in normal flow; mounted inactive/exiting panels are absolutely positioned by the runtime so they do not affect popup measurement while they animate out.

For exit animations, avoid CSS that force-hides content immediately by data-state (for example display: none on non-active panels), because that bypasses the presence lifecycle.

[data-slot="navigation-menu-popup"][data-starting-style],
[data-slot="navigation-menu-popup"][data-ending-style] {
  opacity: 0;
  transform: scale(0.96);
}

[data-slot="navigation-menu-content"][data-starting-style],
[data-slot="navigation-menu-content"][data-ending-style] {
  opacity: 0;
}

[data-slot="navigation-menu-content"][data-starting-style][data-activation-direction="right"] {
  transform: translateX(2rem);
}

[data-slot="navigation-menu-content"][data-starting-style][data-activation-direction="left"] {
  transform: translateX(-2rem);
}

[data-slot="navigation-menu-content"][data-ending-style][data-activation-direction="right"] {
  transform: translateX(-2rem);
}

[data-slot="navigation-menu-content"][data-ending-style][data-activation-direction="left"] {
  transform: translateX(2rem);
}

Legacy data-motion and --motion-direction are still emitted during panel switches for backward compatibility. That includes both root-level and content-level data-motion output, but data-activation-direction is the canonical directional hook.

--popup-width / --popup-height are the popup shell size vars. While the menu is open, they stay at fixed pixel values so CSS can animate panel-to-panel size changes directly between px targets. Initial open writes the measured size synchronously; the shell does not need an auto reset while open.

CSS Variables

Variable Element Description
--popup-width popup Popup width while open; CSS can animate shell width between pixel values
--popup-height popup Popup height while open; CSS can animate shell height between pixel values
--positioner-width positioner Measured width of the active panel
--positioner-height positioner Measured height of the active panel
--available-width positioner Available width between the active trigger and the viewport edge
--available-height positioner Available height between the active trigger and the viewport edge
--transform-origin popup / viewport / content / positioner Pixel origin anchored to trigger (side + align), scoped to each element’s coordinate space
--viewport-width viewport Legacy alias for the active panel width
--viewport-height viewport Legacy alias for the active panel height
--indicator-left indicator Left offset from list
--indicator-width indicator Width of hovered trigger
--indicator-top indicator Top offset from list
--indicator-height indicator Height of hovered trigger
--motion-direction viewport Legacy switching direction output: 1 (right) or -1 (left)

Deprecated Compatibility

  • navigation-menu-viewport-positioner is a deprecated alias for navigation-menu-positioner.
  • --viewport-width, --viewport-height, root/content data-motion, and --motion-direction are deprecated and planned for removal in the next major release.
  • Content-wrapped navigation-menu-portal / navigation-menu-positioner shells are restore-only compatibility and are also planned for removal in the next major release.

Keyboard Navigation

Within Top-Level Items

Key Action
ArrowLeft Move focus to previous top-level item (submenu trigger or plain link)
ArrowRight Move focus to next top-level item (submenu trigger or plain link)
Tab Move focus to next top-level item in DOM order
Shift+Tab Move focus to previous top-level item in reverse DOM order
ArrowDown Move focus into content panel (only when focused item has submenu content)
Home Move focus to first top-level item
End Move focus to last top-level item
Escape Close menu

Top-level submenu triggers and plain links remain in the natural tab order.

Within Content Panel

Key Action
ArrowDown / ArrowRight Move to next focusable element
ArrowUp / ArrowLeft Move to previous element (returns to trigger at start)
Tab From last content item, move focus to next top-level nav item; if none, move to next focusable after nav root
Shift+Tab From first content item, move focus back to owning trigger
Escape Close menu and return focus to trigger

Behavior

  • Hover: Opens after delayOpen ms, closes after delayClose ms
  • Click: Locks menu open until explicit action (click same trigger, click another trigger, click outside, or Escape); hover does not switch/close while locked
  • Focus: Does not auto-open by default; set openOnFocus / data-open-on-focus="true" to opt in
  • Trigger open focus:
    • Pointer click/tap keeps focus on the trigger
    • Keyboard activation/programmatic click moves focus into menu content (first focusable item, or content panel fallback)
  • Indicator: Plain top-level links participate in indicator positioning when no submenu is open; open submenu state takes precedence
  • Switching: Instant transition between items (no delay)