Navigation MenuATM
Site-level navigation with dropdown content panels.
On this page (16)
§Default
§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.
§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. |
§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.
| State | Description |
|---|---|
default | The panel closed. No config. |
open | The panel open. No config. |
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
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.
Returns | |||||||||
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 | |||||||||
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.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
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
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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.
Returns | ||||||||||||
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.
Returns | ||||||||||||
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.
Returns | ||||||||||||
df$.shadcn.navigationMenuApi.store(el: HTMLElement): Store<{ name: NavigationMenuState; config: NavigationMenuStateConfigs[NavigationMenuState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
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.
| ||||||||||||
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