Navigation menu
The site-wide navigation bar. Some items are links; some open a panel of links with a line of explanation each. The panel exists for one reason — when a destination's name is not enough to choose it, and the alternative is a list of nouns people guess at.
The component
Click a trigger, or press Tab to it and hit Enter, Space or ↓. Esc closes and returns focus to the trigger. Hover-to-open only arms after the first panel has been opened by an intentional action — see below.
It is not a menu
The single most common mistake in this component is reaching for role="menu" because the word fits. It does not.
role="menu" | What we build | |
|---|---|---|
| Meant for | Application commands — Cut, Paste, Delete | Site navigation — links to pages |
| Children | menuitem, which is not a link | <a href>, which is |
| Keyboard | Arrow keys move focus; Tab leaves the whole menu | Tab walks the links, as on any page |
| Screen reader | Announces “menu”, and in forms mode the links become unreachable | Announces links, in a labelled navigation landmark |
So the markup is ordinary: a <nav aria-label="Main">, a <button aria-expanded aria-controls> per panel, and a list of links inside. It is a disclosure, not a menu — and disclosures are the pattern the ARIA Authoring Practices point to for navigation.
The one exception. An account menu holding actions — Sign out, Switch organisation — is a real menu and does take role="menu". The test is whether the items navigate or do something. Ours navigate.
Hover, and the intent problem
Opening on hover is faster for a mouse and hostile to everything else. Four rules make it survivable:
- Hover opens, but only after the first intentional open. A panel that drops the instant the pointer crosses the bar on its way somewhere else is the reason people hate mega menus. Once a panel is open, moving sideways switches instantly — that is where hover earns its keep.
- Closing is delayed by 160ms. The path from trigger to the far corner of a panel crosses dead space; without the delay, the panel closes under the cursor.
- Hover is never the only way in. Click and keyboard do the same thing, and touch devices get click alone —
@media (hover: hover)gates the hover behaviour entirely. - Never open on focus. Tabbing through the bar would blow a panel open on every stop.
Open with no delay, close with one. Asymmetry is what makes the panel feel like it is trying to stay.
Delay the open and snap the close. Every trip to a link becomes a test of steadiness.
The chevron swaps, it does not rotate. Motion animates transform in exactly two places — the bottom sheet and the spinner — and Collapse already settled the chevron question. A nav that rotated one would leave two components a group apart giving opposite answers about the same glyph.
Keyboard
| Key | On a trigger | Inside a panel |
|---|---|---|
| Tab | Moves to the next bar item | Moves through the links, then out and on to the next bar item |
| Enter / Space | Toggles the panel | Follows the link |
| ↓ | Opens and focuses the first link | — |
| Esc | Closes | Closes and returns focus to the trigger |
There is no arrow-key roving between links: they are links, and Tab is how links are traversed. Adding arrow navigation would mean removing them from the tab order, which is precisely the role="menu" trap in a different costume.
Below 768px
At max-width: 767px — the system’s one breakpoint — the bar becomes an accordion. Same markup, same triggers, the panel rendered in flow instead of floating. Nothing is duplicated: a second hidden mobile nav is two things to keep in sync and one of them is always wrong. The demo’s Force accordion button only exists so the layout can be seen above the breakpoint; the behaviour is in the stylesheet, not the demo.
- One panel open at a time, on both layouts.
- Full-width rows, 48px tall — comfortably past the 44px target minimum.
- No hover behaviour at all, because there is no hover.
- The chevron still swaps — down to up. It does not rotate, here or anywhere else.
What goes in a panel
| Rule | Why |
|---|---|
| Every link carries a description | The description is the entire justification for the panel. A panel of bare titles should have been a plain link |
| Descriptions are 3–6 words, no full stop | They are labels, not sentences. Longer, and people read the panel instead of scanning it |
| At most 2 columns and 8 links | Past that it is a sitemap, and the choice gets harder rather than easier |
| Column headings are eyebrows, not links | A heading that navigates competes with the things under it |
| Icons are Lucide, in a hairline tile | The tile is the same 36px hairline square used across the system \u2014 the icon aids recognition and never carries meaning alone |
| No promo cards, images or “featured” blocks | Navigation is the one surface where nothing should be selling |
Specification
| Property | Value |
|---|---|
| Trigger | 40px tall, --text-ui 500, --radius-md; hover and open both use --color-surface-muted |
| Panel | 1px --color-border, --radius-md, --shadow-popover, --z-index-dropdown |
| Offset | 6px below the bar |
| Row | 36px icon tile + 12px gap; 10px padding — 56px with a one-line description, taller when it wraps |
| Chevron | 16px, swapped down → up. Nothing rotates — see Collapse |
| Open / close | 0ms / 160ms |
The panel is the one place in this system where a shadow appears — Elevation allows it for floating layers, and a hairline alone cannot separate a panel from the content it covers.
Markup
<nav class="nv" aria-label="Main">
<div class="nv__bar">
<div class="nv__item">
<button class="nv__trigger" type="button" id="t1"
aria-expanded="false" aria-controls="p1">
Find events
<svg class="nv__chev nv__chev--down" …></svg>
<svg class="nv__chev nv__chev--up" …></svg>
</button>
<div class="nv__panel" id="p1" hidden aria-labelledby="t1">
<p class="nv__coltitle">Browse</p>
<ul class="nv__list">
<li><a class="nv__link" href="/events/">
<span class="nv__ico"><i data-lucide="search"></i></span>
<span>
<span class="nv__ttl">Search events</span>
<span class="nv__desc">By name, city or date</span>
</span>
</a></li>
</ul>
</div>
</div>
<a class="nv__plain" href="/pricing/">Pricing</a>
</div>
</nav>No role anywhere. hidden does the hiding, so the links are genuinely out of the accessibility tree and the tab order when the panel is closed — opacity: 0 would leave them focusable and invisible.
theme.css.