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
| Slot | Required | Description |
|---|---|---|
navigation-menu | Yes | Root <nav> element. |
navigation-menu-list | Yes | Container for items and the optional indicator. |
navigation-menu-item | Yes | Each top-level item. Set data-value for submenu items or omit for plain links. |
navigation-menu-trigger | No | Button that opens a submenu on hover/focus. |
navigation-menu-content | No | Submenu content within an item. |
navigation-menu-viewport | Yes | Shared container where active submenu content is rendered. |
navigation-menu-indicator | No | Optional animated indicator that tracks the active trigger. |
navigation-menu-portal | No | Explicit portal wrapper around the viewport. Generated if omitted. |
navigation-menu-positioner | No | Explicit positioner for the viewport. Generated if omitted. |
navigation-menu-popup | No | Explicit popup container around the viewport. Generated if omitted. |
Generated Attributes
The controller writes the following attributes on initialization and updates them as state changes:
| Attribute | Element(s) | Description |
|---|---|---|
data-active | Item, Trigger | Present on the currently active item and trigger. |
data-state | Trigger, Content | "open" or "closed". |
data-motion-from-start | Content | Present on entering content when the user moves right. |
data-motion-from-end | Content | Present on entering content when the user moves left. |
data-motion-to-start | Content | Present on exiting content when the user moves right. |
data-motion-to-end | Content | Present on exiting content when the user moves left. |
data-instant | Indicator | Present 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
| Variable | Description |
|---|---|
--popup-width | Current width of the popup (px). |
--popup-height | Current height of the popup (px). |
--positioner-width | Current width of the positioner (px). |
--positioner-height | Current height of the positioner (px). |
--available-width | Available viewport width for the positioner (px). |
--available-height | Available viewport height for the positioner (px). |
--transform-origin | CSS transform origin for scale animations. |
On the indicator
| Variable | Description |
|---|---|
--indicator-left | Left offset of the indicator relative to the list (px). |
--indicator-top | Top offset of the indicator relative to the list (px). |
--indicator-width | Width of the active trigger (px). |
--indicator-height | Height of the active trigger (px). |
Events
Outbound
| Event | Detail | Description |
|---|---|---|
navigation-menu:change | { value: string | null } | Fired on the root when the active item changes. |
Inbound
| Event | Detail | Description |
|---|---|---|
navigation-menu:select | { value: string } | Dispatch on the root to open the item with the given value. |
API Reference
Options
| Option | Type | Default | Description |
|---|---|---|---|
delayOpen | number | 0 | Delay before opening on hover, in milliseconds. |
delayClose | number | 0 | Delay before closing on mouse leave, in milliseconds. |
openOnFocus | boolean | false | Whether 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. |
sideOffset | number | 0 | Distance from the trigger to the viewport in pixels. |
alignOffset | number | 0 | Offset along the alignment axis in pixels. |
positionMethod | "absolute" | "fixed" | "absolute" | Positioning strategy for the shared viewport positioner. Use "fixed" for sticky headers. |
safeTriangle | boolean | false | Enables hover safe-triangle behavior for diagonal pointer movement. |
debugSafeTriangle | boolean | false | Renders the safe-triangle polygon for debugging. |
onValueChange | (value: string | null) => void | — | Called when the active item value changes. |
Placement Attributes
Values resolve with precedence: JS options > data-* attributes on the root > defaults.
| Data Attribute | Type | Description |
|---|---|---|
data-side | "top" | "right" | "bottom" | "left" | Preferred placement side. |
data-align | "start" | "center" | "end" | Preferred alignment. |
data-side-offset | number | Distance from the trigger. |
data-align-offset | number | Offset along the alignment axis. |
data-position-method | "absolute" | "fixed" | Positioning strategy. |
data-delay-open | number | Open delay (ms). |
data-delay-close | number | Close delay (ms). |
data-open-on-focus | boolean | Open on focus. |
data-safe-triangle | boolean | Enable safe-triangle. |
Controller
| Member | Type | Description |
|---|---|---|
value | string | null (getter) | Currently active item value. |
open(value) | (value: string) => void | Opens the submenu for the given item value. |
close() | () => void | Closes the current submenu. |
destroy() | () => void | Removes 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.
Plain Links
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.