Navigation MenuATM
Site-level navigation with dropdown content panels.
On this page (15)
§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.
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.
§With a featured block
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.
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 nativeshowPopover(), 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:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
open | boolean | true, false | false | Shown - 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