Skip to content

Navigation Menu

A navigation menu with hover and focus interactions, directional content transitions, and a shared viewport for displaying sub-menus. NavigationMenu supports sticky headers, item indicators, hover-safe triangles, and mega-menu layouts.

import { NavigationMenu } from "@areia/slots";

document.querySelector("#root")!.innerHTML = `
  <nav data-slot="navigation-menu" class="w-96">
    <ul data-slot="navigation-menu-list" class="flex gap-1">
      <li data-slot="navigation-menu-item" data-value="products">
        <button data-slot="navigation-menu-trigger" class="win95-button">
          Products
        </button>
        <div data-slot="navigation-menu-content" class="win95-menu mt-1">
          Product content goes here.
        </div>
      </li>
      <li data-slot="navigation-menu-item" data-value="resources">
        <button data-slot="navigation-menu-trigger" class="win95-button">
          Resources
        </button>
        <div data-slot="navigation-menu-content" class="win95-menu mt-1">
          Resource content goes here.
        </div>
      </li>
    </ul>
    <div data-slot="navigation-menu-viewport" class="win95-inset mt-2 min-h-16 p-3"></div>
  </nav>
`;

const root = document.querySelector(
  '[data-slot="navigation-menu"]',
)!;
const controller = NavigationMenu.createNavigationMenu(root, {
  delayOpen: 200,
  align: "start",
});

Import

import { NavigationMenu } from "@areia/slots";

Usage

Add data-slot="navigation-menu" to a <nav> element. Place items with triggers and content inside a list, and a viewport for rendering active content. Call NavigationMenu.createNavigationMenu(root) to initialize.

import { NavigationMenu } from "@areia/slots";

// Auto-bind a single root
const root = document.querySelector(
  '[data-slot="navigation-menu"]',
)!;
const controller = NavigationMenu.createNavigationMenu(root, {
  delayOpen: 200,
  delayClose: 150,
  align: "start",
  sideOffset: 4,
});

// Auto-bind all unbound roots in scope
const controllers = NavigationMenu.create();

Directional Animations

When the user moves between items, the controller computes the direction of travel and applies data-motion-from-start, data-motion-from-end, data-motion-to-start, or data-motion-to-end on the entering and exiting content. Use these attributes in CSS to drive directional transitions.

[data-slot="navigation-menu-content"][data-motion-from-end] {
  animation: slideFromRight 150ms ease;
}

[data-slot="navigation-menu-content"][data-motion-from-start] {
  animation: slideFromLeft 150ms ease;
}

Expected Markup

The controller expects a root element with data-slot="navigation-menu" and the following sub-slots.

Canonical Markup

<nav data-slot="navigation-menu">
  <ul data-slot="navigation-menu-list">
    <li data-slot="navigation-menu-item" data-value="products">
      <button data-slot="navigation-menu-trigger">
        Products
      </button>
      <div data-slot="navigation-menu-content">
        Mega-menu content for Products
      </div>
    </li>
    <li data-slot="navigation-menu-item" data-value="solutions">
      <button data-slot="navigation-menu-trigger">
        Solutions
      </button>
      <div data-slot="navigation-menu-content">
        Mega-menu content for Solutions
      </div>
    </li>
    <li data-slot="navigation-menu-item">
      <a href="/pricing">Pricing</a>
    </li>
    <div data-slot="navigation-menu-indicator"></div>
  </ul>
  <div data-slot="navigation-menu-viewport"></div>
</nav>

Minimal Markup

The popup stack (portal, positioner, popup) is synthesized automatically while open if not authored. Only navigation-menu-viewport is strictly required for shared viewport mode.

<nav data-slot="navigation-menu">
  <ul data-slot="navigation-menu-list">
    <li data-slot="navigation-menu-item" data-value="products">
      <button data-slot="navigation-menu-trigger">
        Products
      </button>
      <div data-slot="navigation-menu-content">...</div>
    </li>
  </ul>
  <div data-slot="navigation-menu-viewport"></div>
</nav>

Generated Popup Structure

While open, the viewport is wrapped in auto-generated elements:

<div data-slot="navigation-menu-portal">
  <div
    data-slot="navigation-menu-positioner"
    style="position: absolute; ..."
  >
    <div data-slot="navigation-menu-popup">
      <div data-slot="navigation-menu-viewport">
        <!-- Active content is moved here -->
      </div>
    </div>
  </div>
</div>

Data Slots

SlotRequiredDescription
navigation-menuYesRoot <nav> element.
navigation-menu-listYesContainer for items and the optional indicator.
navigation-menu-itemYesEach top-level item. Set data-value for submenu items or omit for plain links.
navigation-menu-triggerNoButton that opens a submenu on hover/focus.
navigation-menu-contentNoSubmenu content within an item.
navigation-menu-viewportYesShared container where active submenu content is rendered.
navigation-menu-indicatorNoOptional animated indicator that tracks the active trigger.
navigation-menu-portalNoExplicit portal wrapper around the viewport. Generated if omitted.
navigation-menu-positionerNoExplicit positioner for the viewport. Generated if omitted.
navigation-menu-popupNoExplicit popup container around the viewport. Generated if omitted.

Generated Attributes

The controller writes the following attributes on initialization and updates them as state changes:

AttributeElement(s)Description
data-activeItem, TriggerPresent on the currently active item and trigger.
data-stateTrigger, Content"open" or "closed".
data-motion-from-startContentPresent on entering content when the user moves right.
data-motion-from-endContentPresent on entering content when the user moves left.
data-motion-to-startContentPresent on exiting content when the user moves right.
data-motion-to-endContentPresent on exiting content when the user moves left.
data-instantIndicatorPresent on first open to disable transition animations.

Sticky Header Support

When the navigation menu is inside a sticky or fixed header, set data-position-method="fixed" on the root to anchor the viewport positioner using position: fixed instead of position: absolute.

CSS Variables

On the positioner / popup

VariableDescription
--popup-widthCurrent width of the popup (px).
--popup-heightCurrent height of the popup (px).
--positioner-widthCurrent width of the positioner (px).
--positioner-heightCurrent height of the positioner (px).
--available-widthAvailable viewport width for the positioner (px).
--available-heightAvailable viewport height for the positioner (px).
--transform-originCSS transform origin for scale animations.

On the indicator

VariableDescription
--indicator-leftLeft offset of the indicator relative to the list (px).
--indicator-topTop offset of the indicator relative to the list (px).
--indicator-widthWidth of the active trigger (px).
--indicator-heightHeight of the active trigger (px).

Events

Outbound

EventDetailDescription
navigation-menu:change{ value: string | null }Fired on the root when the active item changes.

Inbound

EventDetailDescription
navigation-menu:select{ value: string }Dispatch on the root to open the item with the given value.

API Reference

Options

OptionTypeDefaultDescription
delayOpennumber0Delay before opening on hover, in milliseconds.
delayClosenumber0Delay before closing on mouse leave, in milliseconds.
openOnFocusbooleanfalseWhether focusing a trigger opens its content.
side"top" | "right" | "bottom" | "left""bottom"Preferred side of the viewport relative to the trigger.
align"start" | "center" | "end""start"Alignment of the viewport relative to the trigger.
sideOffsetnumber0Distance from the trigger to the viewport in pixels.
alignOffsetnumber0Offset along the alignment axis in pixels.
positionMethod"absolute" | "fixed""absolute"Positioning strategy for the shared viewport positioner. Use "fixed" for sticky headers.
safeTrianglebooleanfalseEnables hover safe-triangle behavior for diagonal pointer movement.
debugSafeTrianglebooleanfalseRenders the safe-triangle polygon for debugging.
onValueChange(value: string | null) => voidCalled when the active item value changes.

Placement Attributes

Values resolve with precedence: JS options > data-* attributes on the root > defaults.

Data AttributeTypeDescription
data-side"top" | "right" | "bottom" | "left"Preferred placement side.
data-align"start" | "center" | "end"Preferred alignment.
data-side-offsetnumberDistance from the trigger.
data-align-offsetnumberOffset along the alignment axis.
data-position-method"absolute" | "fixed"Positioning strategy.
data-delay-opennumberOpen delay (ms).
data-delay-closenumberClose delay (ms).
data-open-on-focusbooleanOpen on focus.
data-safe-trianglebooleanEnable safe-triangle.

Controller

MemberTypeDescription
valuestring | null (getter)Currently active item value.
open(value)(value: string) => voidOpens the submenu for the given item value.
close()() => voidCloses the current submenu.
destroy()() => voidRemoves all event listeners and cleans up the controller.

Controller

The controller is returned by NavigationMenu.createNavigationMenu() and provides imperative control over the navigation menu's state.

import { NavigationMenu } from "@areia/slots";

const root = document.querySelector(
  '[data-slot="navigation-menu"]',
)!;
const controller = NavigationMenu.createNavigationMenu(root, {
  delayOpen: 200,
  align: "start",
  onValueChange: (value) => {
    console.log("Active item:", value);
  },
});

// Open a specific submenu
controller.open("products");

// Read the active value
console.log(controller.value); // "products"

// Close the submenu
controller.close();

// Tear down
controller.destroy();

Listening to Events

const root = document.querySelector(
  '[data-slot="navigation-menu"]',
)!;

root.addEventListener("navigation-menu:change", (event) => {
  const { value } = event.detail;
  console.log("Active item changed:", value);
});

Item Indicator

Place a data-slot="navigation-menu-indicator" inside the list to get an animated indicator that tracks the active trigger. The controller sets CSS variables on it:

[data-slot="navigation-menu-indicator"] {
  position: absolute;
  left: var(--indicator-left);
  top: var(--indicator-top);
  width: var(--indicator-width);
  height: var(--indicator-height);
  transition:
    left 200ms ease,
    width 200ms ease;
  border-radius: 6px;
  background: var(--color-areia-surface-muted);
}

/* Disable transition on first render */
[data-slot="navigation-menu-indicator"][data-instant] {
  transition: none;
}

The controller sets data-instant on the indicator during initial open to prevent the transition from animating on page load.

Hover Safe Triangle

When safeTriangle: true, the controller renders a triangular safe zone between the trigger and the viewport. Moving the pointer through this zone keeps the submenu open, preventing accidental closes when moving diagonally. This is useful for mega-menus with large content areas.

Items without data-value or data-slot="navigation-menu-trigger" are treated as plain links. They receive hover styling and keyboard navigation support but do not open submenus. Focus movement on tab respects these items within the menu structure.