Navigation Menu
stimeo--navigation-menu
Site navigation: panels of links opened one at a time. Not an application menu.
Site navigation. Each top button opens a panel of links, and only one panel is open at a time. The content is plain links, so focus is never trapped and Tab walks them as usual. Esc closes the open panel and returns focus to its button, and so do a click outside and focus leaving the navigation. The left and right keys move between the buttons while their natural Tab order is left alone. Opening on hover is there when you want it, with a delay you choose. If you need an application command menu that the arrow keys walk, use the menubar instead.
Keyboard
| Key | Action |
|---|---|
| → / ← | Move focus between top-level triggers (wrapping). Under RTL the two swap. |
| Esc | Close the open panel and return focus to its trigger. |
| Tab | Move naturally; closes the open panel when focus leaves the nav. |
<%# Markup for the navigation-menu demo.
Two arrangements are shown.
1. Click-only (the default): a global nav where each top item opens/closes a
sub-panel (a set of links). The contents are links and don't use role="menu"
(APG Disclosure navigation). The library handles syncing aria-expanded,
single-open behavior, closing on Escape / outside click / focus leaving, and
moving between top items with the left/right keys.
2. Hover (opt-in): openOnHover plus a hoverArea wrapper — the APG "Disclosure
Navigation with Top-Level Links" arrangement, where each <li> holds a real
link, a small disclosure button, and the panel. Hovering anywhere over the
<li> opens that item's panel; every keyboard path stays identical.
Panel placement and styling are the consumer's CSS (demo.css). %>
<p class="nav-menu__caption"><%= t("components.navigation_menu.demo.click_caption") %></p>
<nav class="nav-menu" data-controller="stimeo--navigation-menu"
aria-label="<%= t("components.navigation_menu.demo.label") %>">
<ul class="nav-menu__list">
<li class="nav-menu__item">
<button
type="button"
class="demo-baritem"
aria-expanded="false"
aria-controls="nav-menu-products"
data-stimeo--navigation-menu-target="trigger"
data-action="click->stimeo--navigation-menu#toggle
keydown->stimeo--navigation-menu#onTriggerKeydown">
<%= t("components.navigation_menu.demo.products.label") %>
</button>
<div id="nav-menu-products" class="nav-menu__panel"
data-stimeo--navigation-menu-target="panel" hidden>
<a class="demo-menuitem" href="#analytics"><%= t(
"components.navigation_menu.demo.products.links.analytics"
) %></a>
<a class="demo-menuitem" href="#automation"><%= t(
"components.navigation_menu.demo.products.links.automation"
) %></a>
<a class="demo-menuitem" href="#reports"><%= t(
"components.navigation_menu.demo.products.links.reports"
) %></a>
</div>
</li>
<li class="nav-menu__item">
<button
type="button"
class="demo-baritem"
aria-expanded="false"
aria-controls="nav-menu-company"
data-stimeo--navigation-menu-target="trigger"
data-action="click->stimeo--navigation-menu#toggle
keydown->stimeo--navigation-menu#onTriggerKeydown">
<%= t("components.navigation_menu.demo.company.label") %>
</button>
<div id="nav-menu-company" class="nav-menu__panel"
data-stimeo--navigation-menu-target="panel" hidden>
<a class="demo-menuitem" href="#about"><%= t(
"components.navigation_menu.demo.company.links.about"
) %></a>
<a class="demo-menuitem" href="#careers"><%= t(
"components.navigation_menu.demo.company.links.careers"
) %></a>
</div>
</li>
</ul>
</nav>
<p class="nav-menu__caption"><%= t("components.navigation_menu.demo.hover_caption") %></p>
<%# openOnHover is opt-in; hoverArea widens the hover region to the whole <li>, so
hovering the top-level link (not just the disclosure button) opens the panel.
The button keeps its own accessible name so keyboard and SR users can tell the
link and its disclosure apart. %>
<nav class="nav-menu" data-controller="stimeo--navigation-menu"
data-stimeo--navigation-menu-open-on-hover-value="true"
aria-label="<%= t("components.navigation_menu.demo.hover.label") %>">
<ul class="nav-menu__list">
<li class="nav-menu__item" data-stimeo--navigation-menu-target="hoverArea">
<a class="demo-baritem nav-menu__link" href="#solutions">
<%= t("components.navigation_menu.demo.hover.solutions.label") %>
</a>
<button
type="button"
class="demo-baritem nav-menu__disclosure"
aria-expanded="false"
aria-controls="nav-menu-solutions"
data-stimeo--navigation-menu-target="trigger"
data-action="click->stimeo--navigation-menu#toggle
keydown->stimeo--navigation-menu#onTriggerKeydown">
<span aria-hidden="true">▾</span>
<span class="visually-hidden"><%= t(
"components.navigation_menu.demo.hover.solutions.toggle"
) %></span>
</button>
<div id="nav-menu-solutions" class="nav-menu__panel"
data-stimeo--navigation-menu-target="panel" hidden>
<a class="demo-menuitem" href="#retail"><%= t(
"components.navigation_menu.demo.hover.solutions.links.retail"
) %></a>
<a class="demo-menuitem" href="#finance"><%= t(
"components.navigation_menu.demo.hover.solutions.links.finance"
) %></a>
</div>
</li>
<li class="nav-menu__item" data-stimeo--navigation-menu-target="hoverArea">
<a class="demo-baritem nav-menu__link" href="#resources">
<%= t("components.navigation_menu.demo.hover.resources.label") %>
</a>
<button
type="button"
class="demo-baritem nav-menu__disclosure"
aria-expanded="false"
aria-controls="nav-menu-resources"
data-stimeo--navigation-menu-target="trigger"
data-action="click->stimeo--navigation-menu#toggle
keydown->stimeo--navigation-menu#onTriggerKeydown">
<span aria-hidden="true">▾</span>
<span class="visually-hidden"><%= t(
"components.navigation_menu.demo.hover.resources.toggle"
) %></span>
</button>
<div id="nav-menu-resources" class="nav-menu__panel"
data-stimeo--navigation-menu-target="panel" hidden>
<a class="demo-menuitem" href="#guides"><%= t(
"components.navigation_menu.demo.hover.resources.links.guides"
) %></a>
<a class="demo-menuitem" href="#blog"><%= t(
"components.navigation_menu.demo.hover.resources.links.blog"
) %></a>
</div>
</li>
</ul>
</nav>
/*
* Presentation-only styles for the navigation-menu demo.
* The library toggles the panel's hidden and the top items' aria-expanded. Panel
* placement (directly below the trigger) is static and the consumer's CSS
* responsibility; use stimeo-ui/positioning for dynamic flip.
*/
/* Caption naming each arrangement (click-only, then the opt-in hover one). */
.nav-menu__caption {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--color-text-muted);
}
/* Space the second arrangement away from the first nav's panels. */
.nav-menu + .nav-menu__caption {
margin-top: 1.75rem;
}
.nav-menu__list {
display: flex;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.nav-menu__item {
position: relative;
}
/* Top-level-links arrangement: a real link next to a small disclosure button,
both inside the <li> that acts as the hoverArea. */
/* Trimmed on the end side so the link and its disclosure button read as one pair. */
.nav-menu__link {
padding-inline-end: 0.25rem;
}
/* Narrower than a labelled bar item — it carries only the chevron — but the same
block padding, so it lines up with the link it belongs to. */
.nav-menu__disclosure {
padding-inline: 0.5rem;
}
.nav-menu__panel {
position: absolute;
top: calc(100% + 0.25rem);
left: 0;
z-index: 10;
display: flex;
flex-direction: column;
min-width: 12rem;
padding: 0.5rem;
background: var(--surface-card);
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
box-shadow: 0 8px 24px rgb(15 23 42 / 0.12);
}
.nav-menu__panel[hidden] {
display: none;
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--navigation-menu"
Targets
| Name | Description | Attribute |
|---|---|---|
trigger
required
|
A top-level disclosure button that toggles its sub-panel; aria-expanded is synced. |
data-stimeo--navigation-menu-target="trigger" |
panel
required
|
The sub-panel of links controlled by a trigger (aria-controls/id); its hidden state is synced. |
data-stimeo--navigation-menu-target="panel" |
hoverArea
|
Optional wrapper that widens the hover region to everything it contains (e.g. the <li> holding a top-level link, its trigger, and its panel); hovering it opens the contained trigger's panel. Wrap exactly one trigger per area. Only active with openOnHover. |
data-stimeo--navigation-menu-target="hoverArea" |
Values
| Name | Description | Attribute |
|---|---|---|
openOnHover
|
Whether hover opens/closes panels (opt-in); default false. Changing it at runtime wires or unwires hover in place. While the pointer holds a panel open, a click on that trigger is a no-op (closing it would leave it un-reopenable until the pointer left and returned). | data-stimeo--navigation-menu-open-on-hover-value |
hoverDelay
|
Delay in ms before hover opens or closes a panel; default 150. | data-stimeo--navigation-menu-hover-delay-value |
Actions
| Name | Description | Action |
|---|---|---|
onTriggerKeydown
|
ArrowLeft/ArrowRight move focus between triggers, keeping their natural Tab order. Unavailable (hidden/disabled) triggers are skipped, and modified arrows (Alt+Left/Right) are left to the browser. | stimeo--navigation-menu#onTriggerKeydown |
toggle
|
Toggles the clicked trigger's panel open or closed (single-open); cancels any pending hover open/close. | stimeo--navigation-menu#toggle |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
aria-expanded |
Trigger | Open/closed state of that panel (true/false). |
hidden |
Panel | Present when closed. |