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.