Components · Navigation menu
Components

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 forApplication commands — Cut, Paste, DeleteSite navigation — links to pages
Childrenmenuitem, which is not a link<a href>, which is
KeyboardArrow keys move focus; Tab leaves the whole menuTab walks the links, as on any page
Screen readerAnnounces “menu”, and in forms mode the links become unreachableAnnounces 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: instant · Close: 160ms
Do

Open with no delay, close with one. Asymmetry is what makes the panel feel like it is trying to stay.

Open: 300ms · Close: instant
Don’t

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

KeyOn a triggerInside a panel
TabMoves to the next bar itemMoves through the links, then out and on to the next bar item
Enter / SpaceToggles the panelFollows the link
Opens and focuses the first link
EscClosesCloses 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

RuleWhy
Every link carries a descriptionThe 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 stopThey are labels, not sentences. Longer, and people read the panel instead of scanning it
At most 2 columns and 8 linksPast that it is a sitemap, and the choice gets harder rather than easier
Column headings are eyebrows, not linksA heading that navigates competes with the things under it
Icons are Lucide, in a hairline tileThe 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” blocksNavigation is the one surface where nothing should be selling

Specification

PropertyValue
Trigger40px tall, --text-ui 500, --radius-md; hover and open both use --color-surface-muted
Panel1px --color-border, --radius-md, --shadow-popover, --z-index-dropdown
Offset6px below the bar
Row36px icon tile + 12px gap; 10px padding — 56px with a one-line description, taller when it wraps
Chevron16px, swapped down → up. Nothing rotates — see Collapse
Open / close0ms / 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.

Sporting Scouter Design System
Every token on this site is generated from theme.css.