Navigation Menu
Site navigation bar whose triggers open panels of links below the header.
c2-navigation-menu is the bar at the top of a site. Each c2-navigation-menu-item child is either a plain link (href) or, as soon as anything is slotted into its panel, a trigger that opens that content below the bar — a stack of c2-navigation-menu-link rows, a multi-column mega panel, or any markup you lay out yourself. Mark the entry of the page being shown current to get aria-current="page" and the indicator line.
A trigger follows its own panel. While the current page is one of the panel’s rows — a current c2-navigation-menu-link, or any element carrying aria-current — the item sets current-group on itself and wears the same styling as a current item, so the closed bar still shows which section the visitor is in. It keeps working as routes change: marking a different row current moves the group. Only the row keeps aria-current, since the trigger is the group and not the page.
For document navigation, add auto-current and the menu derives that state from the URL. It follows hash changes and browser history, chooses an exact section link before a page-level link, and treats the first same-page hash link as the default when the URL has no hash. Router-controlled applications can instead update current-url or call syncCurrent() after changing history. Without auto-current, React, Vue, Angular, and other application code retain full control of each link’s current property.
The bar owns which panel is open: value holds the value of that item and '' when everything is closed, and value-change fires on every change. Panels open on hover after open-delay and close close-delay after the pointer has left both the item and the panel, so crossing the gap between them is forgiving; a click on a trigger opens its panel straight away. Set open-on="click" for a bar that ignores hover, where a second click closes the panel again.
Each panel is a c2-overlay popover on the top layer, so a header with overflow: hidden or a stacking context never clips it, and it flips or shifts to stay in the viewport. Dismissal belongs to the bar rather than to the browser: a press anywhere outside it, or Escape, closes the open panel.
The bar is always one row — it never wraps. mode="mobile" is the other layout: the row becomes a single button that opens the whole navigation as one hierarchy, every plain link a row and every panel item a heading with its rows underneath. Add collapsible and those groups open one at a time instead, the open one being value exactly as in the bar, and the list opens on the group holding the current page. Set mobile-breakpoint to a viewport width and the bar switches itself below it; leave it unset and mode is yours to drive.
Keyboard: every item is a tab stop. On a trigger, ArrowLeft / ArrowRight and Home / End walk the bar — and while a panel is open, moving along keeps browsing panels — ArrowDown, Enter or Space opens the panel and moves focus to its first link, and Escape closes it and returns focus to the trigger. Tab from an open trigger walks straight into the panel.
Import
import '@c2n/components/navigation-menu'c2-navigation-menu-item and c2-navigation-menu-link ship in the same package and are registered with it.
Usage
Hover or click a trigger to open its panel.
Switching to the mobile layout
mobile-breakpoint is a viewport width in pixels: at or below it the bar sets mode="mobile" itself, above it it goes back to bar. Nothing else changes — the same markup, the same value, the same value-change.
<c2-navigation-menu aria-label="Main" mobile-breakpoint="800" collapsible>
<c2-navigation-menu-item value="products">…</c2-navigation-menu-item>
</c2-navigation-menu>mode reflects once it has switched, so the mobile layout is in the DOM (c2-navigation-menu[mode='mobile']) for your own CSS to hang off. The default bar is not written onto a bare element, so match it as c2-navigation-menu:not([mode='mobile']).
Leave mobile-breakpoint unset when the switch depends on something the window width does not describe — a container width, a user setting, an app-wide breakpoint you already track — and drive mode yourself:
const nav = document.querySelector('c2-navigation-menu')
const small = matchMedia('(max-width: 800px)')
const apply = () => {
nav.mode = small.matches ? 'mobile' : 'bar'
nav.collapsible = small.matches // optional: one group open at a time
}
apply()
small.addEventListener('change', apply)Following the current URL
Use auto-current for ordinary document navigation. The matching link receives current, including aria-current="page", and a panel trigger automatically receives current-group when one of its rows matches.
<c2-navigation-menu aria-label="Main" auto-current>
<c2-navigation-menu-item value="products">
Products
<div slot="panel">
<c2-navigation-menu-link href="/products#overview">Overview</c2-navigation-menu-link>
<c2-navigation-menu-link href="/products#security">Security</c2-navigation-menu-link>
</div>
</c2-navigation-menu-item>
<c2-navigation-menu-item href="/pricing">Pricing</c2-navigation-menu-item>
</c2-navigation-menu>hashchange and popstate are handled automatically. Routers that call history.pushState() can provide their current URL as controlled state or explicitly synchronize after navigation:
const nav = document.querySelector('c2-navigation-menu')
nav.currentUrl = router.currentUrl
// Or, after changing history without a popstate event:
nav.syncCurrent(router.currentUrl)Turning auto-current off restores the current attributes originally authored in the menu.
Customizing the mobile button
The default button is a hamburger named by mobile-label, themed through --c2-navigation-menu__mobile-trigger--* (size, colour, background, border, radius, icon size, plus __hover and __focus). Put your own control in the mobile-trigger slot to replace it outright — the list still opens from it, anchors to it and returns focus to it.
<c2-navigation-menu aria-label="Main" mobile-breakpoint="800">
<c2-button slot="mobile-trigger">
<svg slot="prefix-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M4 7h16M4 12h16M4 17h16"></path>
</svg>
Menu
</c2-button>
<c2-navigation-menu-item value="products">…</c2-navigation-menu-item>
</c2-navigation-menu>Reacting to the open panel
value-change fires whenever the open panel changes, including when the bar closes. Drive it from code with open(value) and close(), or by setting value.
const nav = document.querySelector('c2-navigation-menu')
nav.addEventListener('value-change', (event) => {
console.log(event.detail.value) // 'products', then '' when it closes
})
nav.open('products')
nav.close()
// An item without a `value` is given `item-<position>` so it stays addressable.
nav.value = 'item-2'Rendering from Astro
Make the bar the island and hand it its items as plain HTML. Written as tags inside <NavigationMenu client:load>, the items are claimed by @astrojs/lit too, since importing the package registers them on the server: each one is server-rendered with declarative shadow DOM, its href is set as a property (it is not reflected, so the link is missing from the HTML) and it is marked defer-hydration, which nothing removes for a child that is not an island itself. Serialize the items to a string and pass it through set:html instead; the renderer leaves that string alone, and the bar’s module upgrades the items in the browser.
---
import { NavigationMenu } from '@c2n/components/navigation-menu'
const escape = (value: string) => value.replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"')
const links = [
{ value: 'docs', label: 'Docs', href: '/docs/', current: true },
{ value: 'blog', label: 'Blog', href: '/blog/', current: false },
]
const items = links
.map(({ value, label, href, current }) => `<c2-navigation-menu-item value="${escape(value)}" href="${escape(href)}"${current ? ' current' : ''}>${escape(label)}</c2-navigation-menu-item>`)
.join('')
---
<NavigationMenu client:load aria-label="Main">
<Fragment set:html={items} />
</NavigationMenu>Check the built page rather than the dev server: every <c2-navigation-menu-item> in dist/**/*.html should carry its href attribute and no defer-hydration. The same applies to rows inside a panel, since c2-navigation-menu-link keeps its href as an unreflected property as well.
Theming
--c2-navigation-menu--* covers the bar itself (gap, padding, background, border, radius). --c2-navigation-menu-item--* is a trigger, with __hover, __expanded, __current, __focus and __disabled states, an __icon part and the __indicator line under the current or open item. The panel surface hangs off the item as --c2-navigation-menu-item__panel--*, and the rows inside it are --c2-navigation-menu-link--*, including __description and __current.
current-group is an attribute like any other, so a group that should read more quietly than the current page can be styled apart: c2-navigation-menu-item[current-group] { --c2-navigation-menu-item__indicator--color: #d4d4d8 }.
The panel surface belongs to the item, so set --c2-navigation-menu-item__panel--* on the item that needs it. Put a panel width on the bar instead and every panel of that bar inherits it — a mega panel and a short link stack then come out exactly the same width. Left alone, each panel is as wide as its own content, with the 260px floor underneath.
c2-navigation-menu.header c2-navigation-menu-item[value='products'] {
--c2-navigation-menu-item__panel--min-width: 520px;
}
c2-navigation-menu.header {
--c2-navigation-menu--gap: 2px;
--c2-navigation-menu-item--color: #52525b;
--c2-navigation-menu-item__hover--background: #f4f4f5;
--c2-navigation-menu-item__indicator--color: #7c3aed;
--c2-navigation-menu-item__panel--border-top-left-radius: 14px;
--c2-navigation-menu-item__panel--border-top-right-radius: 14px;
--c2-navigation-menu-item__panel--border-bottom-left-radius: 14px;
--c2-navigation-menu-item__panel--border-bottom-right-radius: 14px;
--c2-navigation-menu-link__hover--background: #f5f3ff;
--c2-navigation-menu-link__current--color: #7c3aed;
}The indicator is the line under the item that is current, holds the current page, or has its panel open. It spans the item’s whole width and squares off the bottom corners of an open trigger so the two read as one surface. Take it away entirely with a height of 0 — the states are still marked by their colour and background:
c2-navigation-menu.no-indicator {
--c2-navigation-menu-item__indicator--height: 0;
/* Nothing meets the panel any more, so the open trigger can keep its rounded corners. */
--c2-navigation-menu-item__expanded--border-bottom-left-radius: 6px;
--c2-navigation-menu-item__expanded--border-bottom-right-radius: 6px;
--c2-navigation-menu-item__expanded--background: #e4e4e7;
}Or move it: --c2-navigation-menu-item__indicator--inset pulls it in from both ends, --c2-navigation-menu-item__indicator--bottom lifts it off the edge, and --c2-navigation-menu-item__indicator--height and --c2-navigation-menu-item__indicator--color do the rest.
The mobile layout has its own families: --c2-navigation-menu__mobile-trigger--* for the button, --c2-navigation-menu__mobile-panel--* for the list surface (set its min-width to calc(100vw - 32px) for a sheet that spans the screen), and on the item --c2-navigation-menu-item__mobile-heading--* and --c2-navigation-menu-item__mobile-group--indent for the group headings and their inset rows.
The panel is positioned by c2-overlay: --c2-overlay--offset-y sets the gap under the bar and --c2-overlay--viewport-padding how close a panel may come to the window edge. The panel content is your own markup, so give it a layout that survives the mobile list: a repeat(auto-fit, minmax(…, 1fr)) grid stacks on its own, a fixed two-column one does not.
Every variable, with its default, is listed on the API tab, and the Gallery tab has ready-made looks to start from.