Theme
Design your own
On this page (16)
Component Skill — components/navigation-menu/component-skill.md

Native basis

<nav> + <ul> for site-level navigation with dropdown panels.

Web Platform APIs

<nav>popover APIpopovertargetCSS Anchor Positioning@starting-style:has()prefers-reduced-motionforced-colors

Classes

.nav-menu-list.nav-menu-link.nav-menu-trigger.nav-menu-content.nav-menu-content-link.nav-menu-grid.nav-menu-section.nav-menu-heading.nav-menu-icon.nav-menu-feature.nav-menu-footer

Megamenu attributes

.nav-menu-content[data-width="wide"]the panel takes the whole menu's width.nav-menu-content[data-width="full"]the panel spans the page (content kept to 72rem).nav-menu-grid[data-columns]2, 3, 4 columns (default: auto-fit, min 12rem).nav-menu[data-orientation="vertical"]a side menu, panels fly out to the right (or below when there is no room).nav-menu[data-orientation="responsive"]vertical below 48rem, horizontal above

§Default

§With dropdowns

Triggers open anchor-positioned content panels. Chevron rotates when open.

§Megamenu

For large sites, a panel is a whole page of navigation: columns of sections with headings, links with icons and descriptions, a featured block, a footer row. data-width="wide" gives a panel the menu's own width, "full" the page's; .nav-menu-grid lays out the columns. It stays native - every panel is a popover, one open at a time, Escape and outside clicks close it.

§Megamenu with columns

A wide panel (data-width="wide" - the menu's width) with three .nav-menu-section columns: a heading, links with a .nav-menu-icon and a description, and a .nav-menu-footer row.

Two link columns and a .nav-menu-feature promo block (image, title, text) in the third column.

§Lists side by side

A panel sized to its content with horizontal groups: data-columns="2" on the grid, plain links in each column.

§Full width in a navbar

A .navbar with the menu in its center; data-width="full" spans the panel across the page and keeps its content to 72rem.

§Vertical

data-orientation="vertical": a side menu; each panel flies out to the right of its trigger and drops below it when there is no room at the side.

§Responsive

data-orientation="responsive": horizontal from 48rem, a vertical list below it (fixed columns collapse to one). Try the device sizes.

§Without arrows

The chevron is markup, not CSS: leave the svg out of the trigger for arrowless triggers.

§Sizes

Set data-size on the .nav-menu nav; links, triggers and popover rows scale together.

§Density

Set data-density on the .nav-menu nav to scale the gap between top-level items. A whitespace policy, not a zoom: only the row gap scales; link padding belongs to data-size. comfortable matches the unsized default.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - hidden (the authored state; the trigger toggles it)
  • open - shown via the native showPopover(), anchored below its trigger

The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/navigation-menu-{state}.png.

Machine contract - verified against navigation-menu.schema.json by bun run verify:

StateTypeValuesDefaultDescription
openbooleantrue, falsefalseShown - driven through the component's own State API.

§API

Generated from navigation-menu.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type NavigationMenuState = 'default' | 'open' - setState(name, config) takes the config of the state it names.

StateDescription
default
The panel closed.

No config.

open
The panel open.

No config.

Every element

MemberDescription
el.api.setState<S extends NavigationMenuState>(name: S, config?: NavigationMenuStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?NavigationMenuStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: NavigationMenuState; config: NavigationMenuStateConfigs[NavigationMenuState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: NavigationMenuState; config: NavigationMenuStateConfigs[NavigationMenuState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: NavigationMenuState; config: NavigationMenuStateConfigs[NavigationMenuState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: NavigationMenuState; config: NavigationMenuStateConfigs[NavigationMenuState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: NavigationMenuState; config: NavigationMenuStateConfigs[NavigationMenuState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.navigationMenuApi.setState<S extends NavigationMenuState>(el: HTMLElement, name: S, config?: NavigationMenuStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?NavigationMenuStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.navigationMenuApi.getState(el: HTMLElement): { name: NavigationMenuState; config: NavigationMenuStateConfigs[NavigationMenuState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: NavigationMenuState; config: NavigationMenuStateConfigs[NavigationMenuState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.navigationMenuApi.render(state: { name: NavigationMenuState; config: NavigationMenuStateConfigs[NavigationMenuState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: NavigationMenuState; config: NavigationMenuStateConfigs[NavigationMenuState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.navigationMenuApi.store(el: HTMLElement): Store<{ name: NavigationMenuState; config: NavigationMenuStateConfigs[NavigationMenuState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: NavigationMenuState; config: NavigationMenuStateConfigs[NavigationMenuState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.navigationMenuApi.commit<S extends NavigationMenuState>(el: HTMLElement, name: S, config?: NavigationMenuStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?NavigationMenuStateConfigs[S]its config
df$.shadcn.navigationMenuStates: NavigationMenuState[]The declared states, 'default' first: default, open.

§CSS view file

Styles for the navigation-menu component. Uses design tokens for colors, spacing, and radius.

@layer components {
  .nav-menu-list {
    display: flex;
    align-items: center;
    gap: 0.25rem;
    list-style: none;
    margin: 0;
    padding: 0;
  }
  .nav-menu-link,
  .nav-menu-trigger {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    padding: 0.5rem 0.75rem;
    border: none;
    border-radius: var(--radius-md);
    background: transparent;
    color: var(--foreground);
    font-size: 0.875rem;
    font-weight: 500;
    font-family: inherit;
    text-decoration: none;
    cursor: pointer;
    transition:
      background-color 150ms ease,
      color 150ms ease;
    outline: none;
    &:hover {
      background-color: var(--accent);
      color: var(--accent-foreground);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
    & svg {
      width: 0.875rem;
      height: 0.875rem;
      color: var(--muted-foreground);
      transition: transform 200ms ease;
    }
  }
  /* -- Density: set data-density on the .nav-menu nav to scale the gap
     between top-level items (0.5× / 1 / 1.5× of the 0.25rem base);
     comfortable matches the unsized default. Link padding belongs to
     data-size - density is the row rhythm only. */
  .nav-menu:where([data-density="compact"]) .nav-menu-list {
    gap: 0.125rem;
  }
  .nav-menu:where([data-density="comfortable"]) .nav-menu-list {
    gap: 0.25rem;
  }
  .nav-menu:where([data-density="spacious"]) .nav-menu-list {
    gap: 0.375rem;
  }
  /* -- Sizes: set data-size on the .nav-menu nav; every link/trigger and
     the popover's rows scale together. md == the unsized default. */
  .nav-menu[data-size="xs"] {
    & :is(.nav-menu-link, .nav-menu-trigger) {
      padding: 0.25rem 0.5rem;
      font-size: 0.75rem;
    }
    & .nav-menu-content-link {
      padding: 0.25rem 0.5rem;
      font-size: 0.75rem;
    }
  }
  .nav-menu[data-size="sm"] {
    & :is(.nav-menu-link, .nav-menu-trigger) {
      padding: 0.375rem 0.625rem;
      font-size: 0.8125rem;
    }
    & .nav-menu-content-link {
      padding: 0.375rem 0.625rem;
      font-size: 0.8125rem;
    }
  }
  .nav-menu[data-size="md"] {
    & :is(.nav-menu-link, .nav-menu-trigger) {
      padding: 0.5rem 0.75rem;
      font-size: 0.875rem;
    }
  }
  .nav-menu[data-size="lg"] {
    & :is(.nav-menu-link, .nav-menu-trigger) {
      padding: 0.625rem 1rem;
      font-size: 1rem;
    }
    & .nav-menu-content-link {
      padding: 0.625rem 1rem;
      font-size: 1rem;
    }
  }
  .nav-menu[data-size="xl"] {
    & :is(.nav-menu-link, .nav-menu-trigger) {
      padding: 0.75rem 1.25rem;
      font-size: 1.125rem;
    }
    & .nav-menu-content-link {
      padding: 0.75rem 1.25rem;
      font-size: 1.125rem;
    }
  }
  /* Rotate chevron when popover is open */
  .nav-menu-trigger:has(+ .nav-menu-content:popover-open) svg {
    transform: rotate(180deg);
  }
  .nav-menu-content {
    position: fixed;
    inset: auto;
    margin: 0;
    border: 1px solid var(--border);
    border-radius: var(--radius-xl);
    background-color: var(--popover);
    color: var(--popover-foreground);
    padding: 1rem;
    box-shadow: var(--shadow-md);
    min-width: 14rem;
    opacity: 0;
    transform: translateY(-4px);
    transition:
      opacity 150ms ease,
      transform 150ms ease,
      display 150ms allow-discrete;
    /* -- Anchor positioning -- */
    top: anchor(bottom);
    left: anchor(left);
    margin-top: 4px;
    position-try-fallbacks: flip-block;
    &:popover-open {
      opacity: 1;
      transform: translateY(0);
    }
  }
  @starting-style {
    .nav-menu-content:popover-open {
      opacity: 0;
      transform: translateY(-4px);
    }
  }
  .nav-menu-content-link {
    display: block;
    padding: 0.5rem 0.75rem;
    border-radius: var(--radius-md);
    text-decoration: none;
    color: var(--foreground);
    font-size: 0.875rem;
    transition: background-color 150ms ease;
    &:hover {
      background-color: var(--accent);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
    & p {
      margin: 0.125rem 0 0;
      font-size: 0.75rem;
      color: var(--muted-foreground);
      font-weight: 400;
    }
  }
  /* -- Megamenu ------------------------------------------------------
     The .nav-menu is an anchor (scoped per menu) so a panel can take the
     menu's own width - data-width="wide" - or the page's - "full". Inside a
     panel: .nav-menu-grid (auto-fit columns, or data-columns 2 / 3 / 4) of
     .nav-menu-section (a .nav-menu-heading + links), links with a
     .nav-menu-icon and a description, a .nav-menu-feature promo block and a
     .nav-menu-footer row. CSS only - the triggers stay native popovers. */
  .nav-menu {
    anchor-name: --nav-menu-root;
    anchor-scope: --nav-menu-root;
  }
  .nav-menu-content[data-width="wide"] {
    left: anchor(--nav-menu-root left, 0px);
    right: anchor(--nav-menu-root right, 0px);
    width: auto;
  }
  .nav-menu-content[data-width="full"] {
    left: 0;
    right: 0;
    width: auto;
    border-radius: 0;
    border-inline: 0;
    padding-inline: max(1rem, calc((100vw - 72rem) / 2));
  }
  /* the open trigger stays highlighted; aria-current marks the page */
  .nav-menu-trigger:has(+ .nav-menu-content:popover-open),
  .nav-menu-link[aria-current="page"] {
    background-color: var(--accent);
    color: var(--accent-foreground);
  }
  .nav-menu-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
    gap: 1rem 1.5rem;
    &[data-columns="2"] {
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }
    &[data-columns="3"] {
      grid-template-columns: repeat(3, minmax(0, 1fr));
    }
    &[data-columns="4"] {
      grid-template-columns: repeat(4, minmax(0, 1fr));
    }
  }
  .nav-menu-section {
    display: grid;
    align-content: start;
    gap: 0.125rem;
    min-width: 0;
  }
  .nav-menu-heading {
    margin: 0 0 0.25rem;
    padding: 0 0.75rem;
    font-size: 0.6875rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--muted-foreground);
  }
  /* a link with an icon: icon | title over description */
  .nav-menu-content-link:has(> .nav-menu-icon) {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    column-gap: 0.75rem;
    align-items: start;
    & > :not(.nav-menu-icon) {
      grid-column: 2;
    }
  }
  .nav-menu-icon {
    grid-row: span 2;
    display: grid;
    place-items: center;
    width: 2rem;
    height: 2rem;
    border-radius: var(--radius-md);
    background-color: var(--muted);
    color: var(--foreground);
    & svg {
      width: 1rem;
      height: 1rem;
    }
  }
  /* a promo / featured block: image (optional) + title + text */
  .nav-menu-feature {
    display: grid;
    align-content: end;
    gap: 0.25rem;
    min-height: 9rem;
    padding: 1rem;
    border-radius: var(--radius-lg);
    background: linear-gradient(160deg, color-mix(in oklch, var(--primary) 14%, var(--muted)), var(--muted));
    color: var(--foreground);
    text-decoration: none;
    & img {
      width: 100%;
      aspect-ratio: 16 / 9;
      object-fit: cover;
      border-radius: var(--radius-md);
      margin-bottom: 0.5rem;
    }
    & strong {
      font-size: 0.9375rem;
    }
    & p {
      margin: 0;
      font-size: 0.8125rem;
      color: var(--muted-foreground);
    }
    &:hover {
      background: linear-gradient(160deg, color-mix(in oklch, var(--primary) 22%, var(--muted)), var(--muted));
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 2px;
    }
  }
  .nav-menu-footer {
    grid-column: 1 / -1;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem 1rem;
    margin-top: 0.75rem;
    padding-top: 0.75rem;
    border-top: 1px solid var(--border);
    font-size: 0.8125rem;
    color: var(--muted-foreground);
    & a {
      color: var(--foreground);
      font-weight: 500;
      text-decoration: none;
    }
    & a:hover {
      text-decoration: underline;
    }
  }
  /* -- Vertical: a side menu whose panels fly out to the right (and drop
     below the trigger when there is no room). "responsive" is vertical
     below 48rem and horizontal from there up. */
  .nav-menu[data-orientation="vertical"] {
    & .nav-menu-list {
      flex-direction: column;
      align-items: stretch;
    }
    & :is(.nav-menu-link, .nav-menu-trigger) {
      justify-content: space-between;
      width: 100%;
    }
    & .nav-menu-trigger svg {
      transform: rotate(-90deg);
    }
    & .nav-menu-content {
      top: anchor(top);
      left: anchor(right);
      margin: 0 0 0 4px;
      position-try-fallbacks: --nav-menu-below;
    }
  }
  @media (max-width: 47.99rem) {
    .nav-menu[data-orientation="responsive"] {
      & .nav-menu-list {
        flex-direction: column;
        align-items: stretch;
      }
      & :is(.nav-menu-link, .nav-menu-trigger) {
        justify-content: space-between;
        width: 100%;
      }
      & .nav-menu-trigger svg {
        transform: rotate(-90deg);
      }
      & .nav-menu-content {
        top: anchor(top);
        left: anchor(right);
        margin: 0 0 0 4px;
        position-try-fallbacks: --nav-menu-below;
      }
      & .nav-menu-grid[data-columns] {
        grid-template-columns: minmax(0, 1fr);
      }
    }
  }
  /* -- Accessibility ------------------------------------------ */
  @media (prefers-reduced-motion: reduce) {
    .nav-menu-link,
    .nav-menu-trigger,
    .nav-menu-content,
    .nav-menu-content-link {
      transition: none;
    }
    .nav-menu-trigger svg {
      transition: none;
    }
  }
  @media (forced-colors: active) {
    .nav-menu-content {
      border-color: ButtonText;
    }
  }
}
/* the vertical menu's fallback when a panel has no room at the side:
   below its trigger (top-level: @position-try is not a layered rule) */
@position-try --nav-menu-below {
  top: anchor(bottom);
  left: anchor(left);
  margin: 4px 0 0;
}

§JavaScript view file

Sets CSS anchor positioning names for trigger→content pairs. The popover API handles open/close natively.

// -- Navigation Menu -----------------------------------------
// CSS anchor positioning for dropdown navigation menus, plus the named-state
// API so agents/tests can drive menus open/closed by name (AGENTS.md
// "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import {
  defussGlobals,
  safeShowPopover,
  defussQuery,
  componentState,
  bindComponent,
} from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const navigationMenuStates = ['default', 'open'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state - the navigation menu's states take none. */
export interface NavigationMenuStateConfigs {
  /** The panel closed. */
  default: {};
  /** The panel open. */
  open: {};
}
/**
 * The markup of a state: none - 'open' lives in the top layer
 * (:popover-open), not in an attribute, so every state renders the authored
 * markup. render() stays the State API's markup function all the same.
 */
function applyMarkup(_el, _stateName) {}
/**
 * UI side of setState: 'default' hides, 'open' shows. Open/close mechanics
 * stay native (Popover API via popovertarget on the trigger).
 */
function triggerStateChange(content, stateName, _config) {
  switch (stateName) {
    case 'default':
      try {
        content.hidePopover();
      } catch {
        /* already closed */
      }
      break;
    case 'open':
      // deferred show (safeShowPopover): calling showPopover() on an element
      // mid-its-own exit animation - e.g. just light-dismissed by a sibling
      // trigger's click - crashed the headless renderer. Sibling exclusion
      // stays native via popover="auto".
      safeShowPopover(content);
      break;
  }
}
/** Registry-level API; pass the content element explicitly. Unknown names throw. */
export const navigationMenuApi = componentState({
  component: 'navigation-menu',
  states: navigationMenuStates,
  apply: (content, state) => triggerStateChange(content, state.name, state.config),
  markup: (el, state) => applyMarkup(el, state.name),
});
df$.navigationMenuApi = navigationMenuApi;
df$.navigationMenuStates = navigationMenuStates;
function init() {
  // Wiring is per trigger→panel PAIR, not per wrapper: a consumer may compose
  // the menu inside another component (e.g. site-header's <nav>) without a
  // .nav-menu ancestor. Scanning wrappers left those panels unanchored —
  // position-anchor stayed 'normal' and the popover fell back to the viewport
  // top-left (reported twice: site-header Default + Sticky).
  dfDollar('.nav-menu-trigger[popovertarget]:not([data-init])')
    .toArray()
    .forEach((trigger) => {
      trigger.dataset.init = '';
      const content = dfDollar('#' + CSS.escape(trigger.getAttribute('popovertarget'))).get(0);
      if (!content) return;
      // CSS anchor positioning - unique name per trigger-content pair
      const anchorId = `--nav-menu-${content.id}`;
      trigger.style.anchorName = anchorId;
      content.style.positionAnchor = anchorId;
    });
  // bind-scope the api per content element: `$('#nav-products').api.setState('open')`
  dfDollar('.nav-menu-content[popover]:not([data-init])')
    .toArray()
    .forEach((content) => {
      content.dataset.init = '';
      // el.store + el.api (AGENTS.md "State through stores")
      bindComponent(content, navigationMenuApi);
    });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub