Nav menu
Nav menu groups related pages under a single entry of your site's navigation, such as "Our firm" leading to the team, the fees and the office. Visitors open it by hovering or clicking the entry, and every page listed inside is a real link, so it can be bookmarked, shared, opened in a new tab and found by search engines. The entry itself shows when one of its pages is the one being visited.
Dependencies
When to use
Use a nav menu in a site's main navigation when several pages belong
together and listing all of them in the bar would crowd it, such as the
pages of a section or the different services of a business. It's for
going somewhere: every entry of the panel is a page. To offer actions
on the current page instead (edit, duplicate, delete), use a
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/nav-menu.js";
import "@eunomia/elements/link.js";
import "@eunomia/elements/icon.js";
<eun-nav-menu label="Our firm">
<eun-link href="/firm/team">Our team</eun-link>
<eun-link href="/firm/fees">Fees</eun-link>
<eun-link href="/firm/office">The office</eun-link>
</eun-nav-menu>
Importing the file registers <eun-nav-menu> as a custom element, with no
further setup needed. Each direct child is one link of the panel: an
eun-link, a plain <a>, or your framework's own link component. eun-icon draws the trigger's
chevron, so import it alongside.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/nav-menu.js";
import "@eunomia/elements/link.js";
import "@eunomia/elements/icon.js";
</script>
<eun-nav-menu label="Our firm">
<eun-link href="/firm/team">Our team</eun-link>
<eun-link href="/firm/fees">Fees</eun-link>
<eun-link href="/firm/office">The office</eun-link>
</eun-nav-menu>
npm install @eunomia/elements
import { NavLink } from "react-router-dom";
import "@eunomia/elements/nav-menu.js";
import "@eunomia/elements/icon.js";
function FirmMenu() {
return (
<eun-nav-menu label="Our firm">
<NavLink to="/firm/team">Our team</NavLink>
<NavLink to="/firm/fees">Fees</NavLink>
<NavLink to="/firm/office">The office</NavLink>
</eun-nav-menu>
);
}
NavLink renders a real <a> and sets aria-current="page" on the active
one, which is all the menu needs to show its trigger as current.
npm install @eunomia/elements
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import "@eunomia/elements/nav-menu.js";
import "@eunomia/elements/link.js";
import "@eunomia/elements/icon.js";
const PAGES = [
{ href: "/firm/team", label: "Our team" },
{ href: "/firm/fees", label: "Fees" },
{ href: "/firm/office", label: "The office" },
];
export function FirmMenu() {
const pathname = usePathname();
return (
<eun-nav-menu>
<span slot="label">Our firm</span>
{PAGES.map((page) => (
<eun-link key={page.href} current={pathname === page.href}>
<Link href={page.href}>{page.label}</Link>
</eun-link>
))}
</eun-nav-menu>
);
}
<Link> renders a real <a> in the server HTML, so the pages stay
crawlable and keep Next's client-side navigation and prefetching, with no
router bridge needed. The label slot puts the trigger's text in the
server HTML too.
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/nav-menu.js";
import "@eunomia/elements/icon.js";
</script>
<template>
<eun-nav-menu label="Our firm">
<router-link to="/firm/team">Our team</router-link>
<router-link to="/firm/fees">Fees</router-link>
<router-link to="/firm/office">The office</router-link>
</eun-nav-menu>
</template>
router-link renders a real <a> and sets aria-current="page" on the
exact active route, which is all the menu needs to show its trigger as
current.
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import { RouterLink, RouterLinkActive } from "@angular/router";
import "@eunomia/elements/nav-menu.js";
import "@eunomia/elements/icon.js";
@Component({
selector: "app-firm-menu",
imports: [RouterLink, RouterLinkActive],
template: `
<eun-nav-menu label="Our firm">
<a routerLink="/firm/team" routerLinkActive ariaCurrentWhenActive="page"
>Our team</a
>
<a routerLink="/firm/fees" routerLinkActive ariaCurrentWhenActive="page"
>Fees</a
>
<a routerLink="/firm/office" routerLinkActive ariaCurrentWhenActive="page"
>The office</a
>
</eun-nav-menu>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class FirmMenuComponent {}
ariaCurrentWhenActive sets aria-current="page" on the active link,
which is all the menu needs to show its trigger as current.
Alternatives
| You want… | Prefers |
|---|---|
| A menu of actions on the current page, such as Edit or Delete, rather than pages to visit | eun-dropdown-button |
| A group of links that expands in place, pushing the content below, such as in a mobile menu | eun-collapse |
| A single page, with nothing to group under it | eun-link |
Guidance
- Give the trigger a short label that names the group, such as "Our firm" or "Services", so visitors know what they'll find before opening it
- Write the links with
eun-linkor your framework's own link component, so they stay real links and keep client-side navigation - Mark the current page on its link, with
currentoraria-current="page": the trigger follows on its own - Keep the panel to a handful of pages, around two to seven
- Putting buttons or actions in the panel: that's what
is foreun-dropdown-button - Making the group's own landing page reachable only through the trigger: the trigger opens the panel, it never navigates, so add that page as the first link of the panel
- Wrapping the links in another element, such as a
<div>or a fragment that renders one: each direct child is a link, so the wrapper would become a single entry - Nesting a nav menu inside another one
Live testing
Properties
eun-nav-menu only needs a label and its links as children. Everything
else is optional.
Nav menu <eun-nav-menu>
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| show-delay | number | 100 | Milliseconds the pointer rests on the trigger before the panel opens, with trigger set to hover |
| hide-delay | number | 200 | Milliseconds before the panel closes once the pointer leaves it, long enough to move from the trigger into the panel, with trigger set to hover |
| aria-label | string | — | The accessible label of the trigger, required when it has no visible label, such as an icon only trigger |
| label | string | — | The trigger's text. Ignored once something is slotted into label |
| icon | EunomiaIconName | — | An optional decorative icon shown before the label. Ignored once the icon slot has content |
| chevron-icon | EunomiaIconName | 'keyboard_arrow_down' | The trailing chevron icon. Ignored once the chevron slot has content |
| hide-chevron | boolean | false | Hides the trailing chevron, leaving the label alone in the trigger |
| trigger | 'hover' | 'click' | 'hover' | What opens the panel. With hover, it opens when the pointer rests on the trigger and a click keeps it open. With click, only a click opens and closes it. Touch screens and the keyboard always use the click behavior |
| placement | 'bottom-start' | 'bottom' | 'bottom-end' | 'top-start' | 'top' | 'top-end' | 'bottom-start' | Where the panel opens relative to the trigger. It flips to the other side on its own when there isn't enough room |
| open | boolean | false | Whether the panel is currently open |
| current | boolean | false | Shows the trigger as the current section. Already set on its own when one of the links is the current page, so only needed when the current page isn't one of them |
| theme | 'light' | 'dark' | 'light' | The color theme of the trigger and the panel. Use dark on a dark background, such as a dark toolbar |
Import the exact TypeScript type behind any property above, see
Slots
| Name | Description |
|---|---|
| (default) | The links, one per direct child : an eun-link, which fills its row on its own, a native anchor, or a framework's own link component |
| label | The trigger's label as light DOM content, so it's part of the server-rendered HTML. Falls back to label when empty |
| icon | Replaces the trigger's leading icon entirely with any content, not just an icon element. Falls back to icon when empty |
| chevron | Replaces the trigger's trailing chevron entirely with any content, not just an icon element. Falls back to chevronIcon when empty. Whatever ends up there still rotates while open, unless that rotation is set to zero |
Events
| Name | Type | Description |
|---|---|---|
| eunchange | ChangeEvent | Fired whenever the panel opens or closes from an interaction. Read the new state from open |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --nav-menu-trigger-color | Sets the text and icon color of the trigger |
| --nav-menu-trigger-color-hover | Sets the text and icon color of the trigger on hover, on keyboard focus and while open |
| --nav-menu-trigger-color-current | Sets the text and icon color of the trigger when it's the current section |
| --nav-menu-trigger-background | Sets the background of the trigger, transparent by default |
| --nav-menu-trigger-background-hover | Sets the background of the trigger on hover, on keyboard focus and while open |
| --nav-menu-trigger-shadow | Sets the box shadow of the trigger, none by default |
| --nav-menu-trigger-shadow-hover | Sets the box shadow of the trigger on hover, on keyboard focus and while open, falling back to --nav-menu-trigger-shadow |
| --nav-menu-trigger-font-size | Sets the font size of the trigger, inherited from the surrounding text by default |
| --nav-menu-trigger-font-weight | Sets the font weight of the trigger |
| --nav-menu-trigger-padding | Sets the padding of the trigger |
| --nav-menu-trigger-padding-block | Sets the vertical padding of the trigger, as one length applied to both sides. Ignored when --nav-menu-trigger-padding is set |
| --nav-menu-trigger-padding-inline | Sets the horizontal padding of the trigger, as one length applied to both sides. Ignored when --nav-menu-trigger-padding is set |
| --nav-menu-trigger-gap | Sets the gap between the trigger's icon, label and chevron |
| --nav-menu-trigger-border-radius | Sets the corner radius of the trigger and its focus ring |
| --nav-menu-focus-outline-color | Sets the color of the trigger's focus ring, and of plain anchors in the panel |
| --nav-menu-icon-size | Sets the size of the leading icon and the chevron, 1.25em by default so they follow the font size |
| --nav-menu-chevron-rotation | Sets the rotation applied to the chevron while open, 180deg by default. Zero disables it |
| --nav-menu-panel-background | Sets the background of the panel |
| --nav-menu-panel-border-color | Sets the border color of the panel |
| --nav-menu-panel-border-radius | Sets the corner radius of the panel |
| --nav-menu-panel-shadow | Sets the box shadow of the panel |
| --nav-menu-panel-padding | Sets the padding of the panel |
| --nav-menu-panel-gap | Sets the gap between the links of the panel |
| --nav-menu-panel-min-width | Sets the minimum width of the panel, which is never narrower than the trigger |
| --nav-menu-panel-max-height | Sets the maximum height of the panel, beyond which it scrolls |
| --nav-menu-offset | Sets the distance between the trigger and the panel |
| --nav-menu-link-color | Sets the color of the links in the panel |
| --nav-menu-link-color-hover | Sets the color of the links in the panel on hover and focus |
| --nav-menu-link-color-current | Sets the color of the current page's link in the panel |
| --nav-menu-link-background-hover | Sets the background of the links in the panel on hover and focus |
| --nav-menu-link-padding | Sets the padding of the links in the panel |
| --nav-menu-link-border-radius | Sets the corner radius of the links in the panel |
| --nav-menu-transition-duration | Sets the duration of the panel's open and close animation, the chevron's rotation and the color transitions |
CSS shadow parts
| Name | Description |
|---|---|
| trigger | The trigger button, for effects its custom properties don't cover, such as an active indicator |
| panel | The floating panel holding the links |
Basic
<eun-nav-menu label="Our firm">
<eun-link href="/firm/team">Our team</eun-link>
<eun-link href="/firm/fees">Fees</eun-link>
<eun-link href="/firm/office">The office</eun-link>
</eun-nav-menu>
Links
With eun-link
eun-link is the recommended link. The menu styles it as a menu row on
its own: it fills the whole row, which takes the hover, the focus ring and
the click, and it has no underline, a hover background and no visited
color. A label too long for the panel is cut with an ellipsis. There's no
need for block here, the menu applies the same layout itself.
With icons
eun-link's own icon shows before each page's label.
<eun-nav-menu label="Services">
<eun-link href="/services/advice" icon="gavel">Legal advice</eun-link>
<eun-link href="/services/contracts" icon="description">Contracts</eun-link>
<eun-link href="/services/litigation" icon="balance">Litigation</eun-link>
</eun-nav-menu>
Plain anchors
A plain <a> works too, with the same row look.
<eun-nav-menu label="Our firm">
<a href="/firm/team">Our team</a>
<a href="/firm/fees">Fees</a>
<a href="/firm/office">The office</a>
</eun-nav-menu>
External link
An eun-link with target="_blank" shows its external icon and announces
the new tab, inside the panel like anywhere else.
<eun-nav-menu label="Resources">
<eun-link href="/guides">Guides</eun-link>
<eun-link href="https://www.legifrance.gouv.fr" target="_blank">
Légifrance
</eun-link>
</eun-nav-menu>
Current page
From the links
Mark the current page on its own link, with current on an eun-link or
aria-current="page" on an anchor. The trigger then takes the current
color too, and announces it, so visitors see which section they're in
before opening the menu.
<eun-nav-menu label="Our firm">
<eun-link href="/firm/team">Our team</eun-link>
<eun-link href="/firm/fees" current>Fees</eun-link>
<eun-link href="/firm/office">The office</eun-link>
</eun-nav-menu>
Set by hand
When the current page belongs to the section without being one of its
links, such as an article under "News", set current on the menu itself.
<eun-nav-menu label="News" current>
<eun-link href="/news">Latest articles</eun-link>
<eun-link href="/news/archive">Archive</eun-link>
</eun-nav-menu>
Trigger
With an icon
icon shows a decorative icon before the label.
<eun-nav-menu label="Account" icon="person">...</eun-nav-menu>
Without a chevron
hide-chevron removes the chevron, leaving the label alone, for a
navigation where every entry should look the same.
<eun-nav-menu label="Our firm" hide-chevron>...</eun-nav-menu>
Custom chevron
chevron-icon swaps the chevron for any icon, and
--nav-menu-chevron-rotation sets how far it turns while open, 180deg by
default. Zero keeps it still. For anything an icon can't express, slot your
own non-interactive content into chevron.
<eun-nav-menu
label="Our firm"
chevron-icon="chevron_right"
style="--nav-menu-chevron-rotation: 90deg;"
>
...
</eun-nav-menu>
Rich label
The label slot takes any inline content, and puts the trigger's text in
the server HTML. Keep it to text and inline phrasing, since it renders
inside the trigger button.
<eun-nav-menu>
<span slot="label">Our <strong>firm</strong></span>
...
</eun-nav-menu>
Icon only
A trigger without visible text needs an aria-label, forwarded to the
trigger button, so it still has an accessible name.
<eun-nav-menu icon="apps" aria-label="Our apps" hide-chevron>...</eun-nav-menu>
Opening
On hover
By default the panel opens once the pointer rests on the trigger for
show-delay (100ms), and closes hide-delay (200ms) after it leaves both
the trigger and the panel. That delay is what lets the pointer travel from
the trigger into the panel without closing it. A click keeps the panel
open wherever the pointer goes, and a second click closes it. Touch
screens and the keyboard have no hover, so they always open and close it
on a tap or a key press.
<eun-nav-menu label="Our firm" show-delay="150" hide-delay="400"
>...</eun-nav-menu
>
On click only
trigger="click" ignores the hover: only a click opens and closes the
panel. Prefer it when the menu sits next to content the pointer often
passes over, or when your navigation mixes nav menus with other click-only
controls.
<eun-nav-menu label="Our firm" trigger="click">...</eun-nav-menu>
Placement
placement sets where the panel opens: bottom-start (default),
bottom, bottom-end, top-start, top or top-end. It flips to the
other side on its own when there isn't enough room, and never overflows
the edges of the screen.
<eun-nav-menu label="Our firm" placement="bottom-end">...</eun-nav-menu>
Following the state
open reflects whether the panel is open, and eunchange fires each time
an interaction opens or closes it. Setting open opens or closes it from
your own code, without firing the event.
const menu = document.querySelector("eun-nav-menu");
menu.addEventListener("eunchange", () => {
console.log(menu.open);
});
In a navigation
In a toolbar
A nav menu sits among the toolbar's other links with the same colors and spacing. Put it in the same slot as them.
<eun-toolbar>
<a slot="start" href="/">...</a>
<eun-link slot="center" href="/">Home</eun-link>
<eun-nav-menu slot="center" label="Our firm">
<eun-link href="/firm/team">Our team</eun-link>
<eun-link href="/firm/fees">Fees</eun-link>
<eun-link href="/firm/office">The office</eun-link>
</eun-nav-menu>
<eun-link slot="center" href="/contact">Contact</eun-link>
</eun-toolbar>
The panel opens above everything else on the page, so the toolbar's blur, shadow or rounded corners never cut it off.
On a dark background
theme="dark" switches the trigger and the panel to light text on a dark
surface, matching eun-link's own theme="dark". With a toolbar's
adaptive-theme, set it from the toolbar's eunchange event.
<eun-nav-menu label="Our firm" theme="dark">...</eun-nav-menu>
Mobile menu
A floating panel doesn't suit a phone. In the toolbar's mobile menu, list
the same links in an eun-collapse instead, which expands in place.
<eun-toolbar>
<!-- Desktop bar -->
<eun-nav-menu slot="center" label="Our firm">...</eun-nav-menu>
<!-- Mobile menu, with no slot -->
<eun-collapse heading="Our firm" chevron-icon="keyboard_arrow_down">
<eun-link href="/firm/team" block>Our team</eun-link>
<eun-link href="/firm/fees" block>Fees</eun-link>
<eun-link href="/firm/office" block>The office</eun-link>
</eun-collapse>
</eun-toolbar>
Integration
SPA navigation
The links are your own, rendered in the light DOM: a router's link
component, such as Next's <Link>, React Router's <NavLink>, Vue's
<router-link> or Angular's routerLink, handles the click itself, with
client-side navigation and prefetching. eun-link with href raises
eunnavigate like anywhere else (see the
Server-side rendering
Before the menu upgrades, its links would show as a plain row of links
next to the label. Link overlay-ssr.css in the page's <head> to keep
them out of sight, and nav-menu-ssr.css to draw the trigger at its final
size, chevron room included, from the first paint:
<link
rel="stylesheet"
href="node_modules/@eunomia/elements/dist/ssr/overlay-ssr.css"
/>
<link
rel="stylesheet"
href="node_modules/@eunomia/elements/dist/ssr/nav-menu-ssr.css"
/>
A bundler that resolves package exports can reach them as
@eunomia/elements/overlay-ssr.css and
@eunomia/elements/nav-menu-ssr.css.
Put the trigger's text in the label slot rather than the label
attribute, so it's in the server HTML. The links stay in the HTML too, so
search engines follow them even while they're hidden.
Custom
Colors and spacing
Override the --nav-menu-* CSS variables, listed in full in the API tab.
The --nav-menu-link-* ones style the links of the panel, and an
eun-link's own --link-* variables still win over them.
.firm-menu {
--nav-menu-trigger-color: #831843;
--nav-menu-trigger-color-hover: #be185d;
--nav-menu-trigger-background-hover: #fce7f3;
--nav-menu-trigger-shadow-hover: 0 2px 8px rgba(190, 24, 93, 0.25);
--nav-menu-trigger-padding: 8px 12px;
--nav-menu-panel-background: #fdf2f8;
--nav-menu-panel-border-color: #fbcfe8;
--nav-menu-panel-padding: 8px;
--nav-menu-panel-gap: 4px;
--nav-menu-link-color: #831843;
--nav-menu-link-color-hover: #be185d;
--nav-menu-link-background-hover: #fce7f3;
--nav-menu-offset: 8px;
}
Parts
The trigger and panel parts reach what the variables don't cover, such
as a bar under the trigger of the current section.
eun-nav-menu::part(trigger) {
position: relative;
}
eun-nav-menu[current]::part(trigger)::after {
content: "";
position: absolute;
inset-inline: 4px;
bottom: -2px;
height: 2px;
border-radius: 2px;
background: currentColor;
}
The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.
| Variable | Sets |
|---|---|
--nav-menu-trigger-color |
Sets the text and icon color of the trigger |
--nav-menu-trigger-color-hover |
Sets the text and icon color of the trigger on hover, focus and while open |
--nav-menu-trigger-color-current |
Sets the text and icon color of the trigger when it's the current section |
--nav-menu-trigger-background-hover |
Sets the background of the trigger on hover, focus and while open |
--nav-menu-trigger-shadow |
Sets the box shadow of the trigger |
--nav-menu-trigger-shadow-hover |
Sets the box shadow of the trigger on hover, focus and while open |
--nav-menu-focus-outline-color |
Sets the color of the focus ring |
--nav-menu-panel-background |
Sets the background of the panel |
--nav-menu-panel-border-color |
Sets the border color of the panel |
--nav-menu-panel-border-radius |
Sets the corner radius of the panel |
--nav-menu-panel-shadow |
Sets the box shadow of the panel |
--nav-menu-link-color |
Sets the color of the links in the panel |
--nav-menu-link-background-hover |
Sets the background of the links in the panel on hover and focus |
Keyboard interactions
| Key | Action |
|---|---|
Tab / Shift+Tab |
Moves to the trigger, then through the links of the open panel, in order |
Enter / Space on the trigger |
Opens or closes the panel, keeping focus on the trigger |
ArrowDown on the trigger |
Opens the panel and focuses its first link |
ArrowUp on the trigger |
Opens the panel and focuses its last link |
ArrowDown / ArrowUp on a link |
Moves to the next or previous link, wrapping at both ends |
Home / End on a link |
Moves to the first or last link |
Enter on a link |
Follows it, and closes the panel |
Escape |
Closes the panel and returns focus to the trigger, without closing an enclosing dialog |
Tabbing past the last link, or anywhere else out of the menu, closes the
panel. The arrows are a shortcut: Tab alone reaches every link.
Semantics and screen readers
Disclosure, not menu
eun-nav-menu follows the
disclosure navigation pattern
of the WAI-ARIA APG, the one recommended for a site's navigation, rather
than the menu pattern eun-dropdown-button uses for actions. A
role="menu" would switch screen readers to an application mode, expect
arrow keys only, and announce "menu items" instead of links.
- The trigger is a native
<button>witharia-expandedandaria-controlspointing at the panel. It never navigates on its own. - The panel is a list labelled by the trigger, with one item per link, so a screen reader announces "Our firm, list, 3 items".
- Every entry stays a real link, announced as a link, with its own destination.
- The closed panel is out of both the tab order and the accessibility tree.
The current page
The current page's link carries aria-current="page", set by eun-link's
current or written on the anchor yourself. The trigger then carries
aria-current="true", so the section is announced as current from the
trigger too, before the panel is even open.
Label accessibility
The trigger's accessible name is its label. An icon-only trigger has
none, since eun-icon renders with aria-hidden="true": set aria-label
on eun-nav-menu, which forwards it to the trigger button. The chevron is
hidden from assistive technology.
Pointer and touch
Content on hover
The panel opened by hover meets
- Dismissible:
Escapecloses it without moving the pointer. - Hoverable: the pointer can move from the trigger into the panel,
thanks to
hide-delay, without it closing. - Persistent: it stays open until the pointer leaves both the trigger and the panel, focus leaves the menu, or it's dismissed.
Touch screens
A touch screen has no hover, so a tap on the trigger opens the panel and
another one closes it, whatever trigger is set to. A tap anywhere else
on the page closes it too.
Target size
The trigger keeps a padding around its label, which keeps it above the
24 by 24 pixels of
--nav-menu-trigger-padding.
Visual and motion
Focus
The trigger and every link show a visible focus ring from the keyboard,
never after a mouse click, colored with --nav-menu-focus-outline-color.
Color contrast
The default colors come from the theme's tokens, already chosen to meet
--nav-menu-trigger-color must be checked against
the background the trigger sits on, and a custom --nav-menu-link-color
against --nav-menu-panel-background. See
Reduced motion
Respects prefers-reduced-motion. The panel's fade and slide, the
chevron's rotation and the color transitions are skipped entirely for
visitors who asked for reduced motion, satisfying
Reference links