Theme
On this page (15)
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.

§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 } from '../../shared/state-api.js';
const df$ = defussGlobals();
const navigationMenuStates = ['default', 'open'];
/**
 * 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 = {
  setState(content, stateName, config = {}) {
    if (!navigationMenuStates.includes(stateName)) {
      throw new Error(`navigation-menu: unknown state "${stateName}" (supported: ${navigationMenuStates.join(', ')})`);
    }
    triggerStateChange(content, stateName, config);
    // state lives on the ELEMENT, not the module (multiple menus per page)
    content.dataset.stateName = stateName;
    content._stateConfig = config;
  },
  getState(content) {
    return { name: content.dataset.stateName || 'default', config: content._stateConfig ?? {} };
  },
};
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).
  document.querySelectorAll('.nav-menu-trigger[popovertarget]:not([data-init])').forEach((trigger) => {
    trigger.dataset.init = '';
    const content = document.getElementById(trigger.getAttribute('popovertarget'));
    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')`
  document.querySelectorAll('.nav-menu-content[popover]:not([data-init])').forEach((content) => {
    content.dataset.init = '';
    content.api = {
      setState: (stateName, config) => navigationMenuApi.setState(content, stateName, config),
      getState: () => navigationMenuApi.getState(content),
    };
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

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