Navigation Menu
navigation-menuuiTop-of-page horizontal navigation with hover/click triggered megamenus. Use for marketing sites and product navs that need rich content — featured links, mini-cards, and submenu columns.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/navigation-menu.json$npx shadcn@latest add https://uipkge.dev/r/lit/navigation-menu.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/navigation-menu.json$bunx shadcn@latest add https://uipkge.dev/r/lit/navigation-menu.jsonManifest:
https://uipkge.dev/r/lit/navigation-menu.jsonInstalls to:src/uip/components/navigation-menu/Examples
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases from this item's source — use them to shape the data you pass in.
HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-navigation-menu': UipNavigationMenu
'uip-navigation-menu-item': UipNavigationMenuItem
'uip-navigation-menu-link': UipNavigationMenuLink
}
}npm dependencies
Includes
Used by
Files installed (2)
src/uip/components/navigation-menu/navigation-menu.ts22.4 kB
import { LitElement, css, html, isServer, nothing, type PropertyDeclarations } from 'lit' import { ChevronDown } from 'lucide' import { cn } from '../../lib/utils' import { icon } from '../../lib/icon' import { tailwind } from '../../lib/styles' import { ThemeController } from '../../lib/theme' import { navigationMenuTriggerStyle } from './navigation-menu.variants' export { navigationMenuTriggerStyle } // React's class strings, verbatim (packages/registry-react/components/navigation-menu). const rootClasses = 'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center' const listClasses = 'group flex flex-1 list-none items-center justify-center gap-1' const itemClasses = 'relative' const chevronClasses = 'relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180' const contentClasses = cn( 'data-[motion^=from-]:motion-safe:animate-in data-[motion^=to-]:motion-safe:animate-out data-[motion^=from-]:motion-safe:fade-in data-[motion^=to-]:motion-safe:fade-out data-[motion=from-end]:motion-safe:slide-in-from-right-52 data-[motion=from-start]:motion-safe:slide-in-from-left-52 data-[motion=to-end]:motion-safe:slide-out-to-right-52 data-[motion=to-start]:motion-safe:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto', 'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:motion-safe:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:motion-safe:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:motion-safe:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:motion-safe:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:motion-safe:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:motion-safe:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none', ) const linkClasses = "data-active:focus:bg-accent data-active:hover:bg-accent data-active:bg-accent/50 data-active:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4" const indicatorClasses = 'data-[state=visible]:motion-safe:animate-in data-[state=hidden]:motion-safe:animate-out data-[state=hidden]:motion-safe:fade-out data-[state=visible]:motion-safe:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden' const viewportWrapperClasses = 'absolute top-full left-0 isolate z-50 flex justify-center' const viewportClasses = 'origin-top-center bg-popover text-popover-foreground data-[state=open]:motion-safe:animate-in data-[state=closed]:motion-safe:animate-out data-[state=closed]:motion-safe:zoom-out-95 data-[state=open]:motion-safe:zoom-in-90 relative left-[var(--radix-navigation-menu-viewport-left)] mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]' /** Run `done` once `el`'s own animations finish (or 400ms, for hidden tabs). */ function afterAnimations(el: Element | null | undefined, done: () => void) { let fired = false const fin = () => { if (fired) return fired = true done() } setTimeout(fin, 400) requestAnimationFrame(() => { const anims = el?.getAnimations() ?? [] if (anims.length) Promise.all(anims.map((a) => a.finished)).then(fin, fin) else fin() }) } const booleanDefaultTrue = { fromAttribute: (v: string | null) => v !== 'false', toAttribute: (v: boolean) => (v ? '' : 'false'), } let uid = 0 /** * <uip-navigation-menu> — the registry NavigationMenu (Radix) as web components. * * <uip-navigation-menu> ← NavigationMenu + List (+ Viewport) * <uip-navigation-menu-item> ← NavigationMenuItem * <span slot="trigger">Getting started</span> ← NavigationMenuTrigger children * <ul slot="content" class="grid w-72 gap-2 p-4">…</ul> ← NavigationMenuContent children * </uip-navigation-menu-item> * <uip-navigation-menu-item> * <uip-navigation-menu-link href="#" trigger-style>Pricing</uip-navigation-menu-link> * </uip-navigation-menu-item> * </uip-navigation-menu> * * Each item renders its trigger, content and (with `viewport`, the default) * the viewport box in its OWN shadow root, so aria-controls/aria-labelledby * resolve; the viewport is absolutely positioned against this element's * `relative` root exactly like React's. Panel content stays light DOM (styled * by the page, links stay real links). * * Properties: `value` (open item's value, '' = closed), `viewport` (default * true; `viewport="false"` renders content under its trigger), `indicator` * (renders NavigationMenuIndicator), `delayDuration` (200), `skipDelayDuration` (300). * Events: `value-change` { value }. */ export class UipNavigationMenu extends LitElement { static styles = [tailwind, css`:host { display: contents; }`] static properties: PropertyDeclarations = { value: { reflect: true }, viewport: { type: Boolean, converter: booleanDefaultTrue, reflect: true }, indicator: { type: Boolean }, delayDuration: { type: Number, attribute: 'delay-duration' }, skipDelayDuration: { type: Number, attribute: 'skip-delay-duration' }, accessibleLabel: { attribute: 'aria-label' }, indicatorState: { state: true }, } value = '' viewport = true indicator = false delayDuration = 200 skipDelayDuration = 300 accessibleLabel?: string private indicatorState: 'visible' | 'hidden' | 'gone' = 'gone' private openTimer?: ReturnType<typeof setTimeout> private closeTimer?: ReturnType<typeof setTimeout> private lastClosedAt = 0 constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'navigation-menu') } get items() { if (isServer) return [] return [...this.children].filter((c): c is UipNavigationMenuItem => c instanceof UipNavigationMenuItem) } private itemFor(value: string) { return this.items.find((i) => i.itemValue === value) } // --- open state ------------------------------------------------------------- setValue(value: string) { if (value === this.value) return this.value = value this.dispatchEvent(new CustomEvent('value-change', { detail: { value }, bubbles: true, composed: true })) } protected willUpdate(changed: Map<string, unknown>) { if (changed.has('value')) { const prev = (changed.get('value') as string | undefined) ?? '' const items = this.items const from = items.findIndex((i) => i.itemValue === prev) const to = items.findIndex((i) => i.itemValue === this.value) if (!this.value) this.lastClosedAt = Date.now() items.forEach((item, i) => { const switching = from >= 0 && to >= 0 if (i === to) item.setExpanded(true, switching ? (to > from ? 'from-end' : 'from-start') : undefined) else if (i === from) item.setExpanded(false, switching ? (to > from ? 'to-start' : 'to-end') : undefined) }) if (this.value) this.indicatorState = 'visible' else if (this.indicatorState === 'visible') { this.indicatorState = 'hidden' this.updateComplete.then(() => afterAnimations(this.renderRoot.querySelector('[data-slot=navigation-menu-indicator]'), () => { if (!this.value) this.indicatorState = 'gone' }), ) } } } disconnectedCallback() { super.disconnectedCallback() removeEventListener('pointerdown', this.onOutsidePointer, true) } // Radix DismissableLayer: a pointerdown outside the menu closes it. private onOutsidePointer = (e: PointerEvent) => { if (!e.composedPath().includes(this)) this.setValue('') } protected updated(changed: Map<string, unknown>) { if (changed.has('value')) { if (this.value) addEventListener('pointerdown', this.onOutsidePointer, true) else removeEventListener('pointerdown', this.onOutsidePointer, true) } if (changed.has('viewport')) this.items.forEach((i) => i.requestUpdate()) if (changed.has('value') && this.value) this.placeIndicator() } // Written straight to the element's style: it's measured after render, and a // reactive property set in updated() would schedule a second update. private placeIndicator() { const trigger = this.itemFor(this.value)?.triggerEl const nav = this.renderRoot.querySelector('nav') const el = this.renderRoot.querySelector<HTMLElement>('[data-slot=navigation-menu-indicator]') if (!trigger || !nav || !el) return const t = trigger.getBoundingClientRect() const n = nav.getBoundingClientRect() // Radix Indicator: absolute, left 0, width = trigger, translateX(offset). Object.assign(el.style, { position: 'absolute', left: '0px', width: `${t.width}px`, transform: `translateX(${t.left - n.left}px)`, }) } // --- hover intent (Radix delayDuration / skipDelayDuration) -------------------- onTriggerEnter(item: UipNavigationMenuItem) { clearTimeout(this.openTimer) clearTimeout(this.closeTimer) const skip = !!this.value || Date.now() - this.lastClosedAt < this.skipDelayDuration if (skip) this.setValue(item.itemValue) else this.openTimer = setTimeout(() => this.setValue(item.itemValue), this.delayDuration) } startClose() { clearTimeout(this.openTimer) clearTimeout(this.closeTimer) this.closeTimer = setTimeout(() => this.setValue(''), 150) } cancelClose() { clearTimeout(this.closeTimer) } toggle(item: UipNavigationMenuItem) { clearTimeout(this.openTimer) clearTimeout(this.closeTimer) this.setValue(this.value === item.itemValue ? '' : item.itemValue) } // --- keyboard: roving focus across the top-level triggers/links ---------------- private onKeyDown(e: KeyboardEvent) { const items = this.items const from = items.findIndex((i) => i.isTopLevelTarget(e.composedPath())) if (from < 0) return let to = -1 if (e.key === 'ArrowRight') to = Math.min(from + 1, items.length - 1) else if (e.key === 'ArrowLeft') to = Math.max(from - 1, 0) else if (e.key === 'Home') to = 0 else if (e.key === 'End') to = items.length - 1 else return e.preventDefault() items[to]?.focusTopLevel() } // Close when focus leaves the whole menu (Radix onFocusOutside). private onFocusOut(e: FocusEvent) { const next = e.relatedTarget as Node | null if (!next || !this.value) return if (this.contains(next) || this.renderRoot.contains(next)) return // relatedTarget is retargeted to a host when focus moves into another // shadow root; our items' shadow roots live under this element. this.setValue('') } render() { return html`<nav aria-label=${this.accessibleLabel ?? 'Main'} data-orientation="horizontal" data-viewport=${this.viewport ? 'true' : 'false'} class=${rootClasses} @keydown=${this.onKeyDown} @focusout=${this.onFocusOut} > <ul data-uipkge="" data-slot="navigation-menu-list" data-orientation="horizontal" class=${listClasses}> <slot></slot> </ul> ${this.indicator && this.indicatorState !== 'gone' ? html`<div aria-hidden="true" data-uipkge="" data-slot="navigation-menu-indicator" data-orientation="horizontal" data-state=${this.indicatorState === 'visible' ? 'visible' : 'hidden'} class=${indicatorClasses} > <div class="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md"></div> </div>` : nothing} </nav>` } } /** * <uip-navigation-menu-item> — one top-level entry. With a `trigger` slot it * renders NavigationMenuTrigger (button + chevron) and NavigationMenuContent * (the `content` slot); otherwise its default slot holds a link. * Property: `value` (defaults to an auto id). * * The host is the list item (`role="listitem"` as a plain attribute, so the * root's <ul> > <slot> > host is a valid list for AT — ElementInternals' * `.role` does not surface for these hosts in Chromium, verified in the * accessibility tree). `part="base"` is React's `<li class="relative">` box: * style it with `class="[&::part(base)]:…"`. */ export class UipNavigationMenuItem extends LitElement { static styles = [tailwind, css`:host { display: block; }`] static properties: PropertyDeclarations = { value: {}, expanded: { state: true }, mounted: { state: true }, motion: { state: true }, hasTrigger: { state: true }, } value?: string private expanded = false private mounted = false private motion?: string private hasTrigger = false private readonly autoValue = `item-${++uid}` private readonly idBase = `uip-nav-${uid}` private ro?: ResizeObserver constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'navigation-menu-item') // The host sits in the root's <ul>; it is the list item. this.setAttribute('role', 'listitem') if (isServer) return const read = () => (this.hasTrigger = !!this.querySelector(':scope > [slot="trigger"]')) read() // Frameworks may render the slotted children after the host connects. this.childObserver = new MutationObserver(read) this.childObserver.observe(this, { childList: true }) } disconnectedCallback() { super.disconnectedCallback() this.ro?.disconnect() this.childObserver?.disconnect() } private childObserver?: MutationObserver get itemValue() { return this.value || this.autoValue } private get root() { return isServer ? null : this.closest('uip-navigation-menu') } get triggerEl() { return this.renderRoot?.querySelector<HTMLButtonElement>('[data-slot=navigation-menu-trigger]') ?? null } private get contentEl() { return this.renderRoot?.querySelector<HTMLElement>('[data-slot=navigation-menu-content]') ?? null } private get defaultSlotted() { const slot = this.renderRoot?.querySelector<HTMLSlotElement>('slot:not([name])') return (slot?.assignedElements()[0] as HTMLElement | undefined) ?? null } isTopLevelTarget(path: EventTarget[]) { const t = this.triggerEl ?? this.defaultSlotted return !!t && path.includes(t) } focusTopLevel() { ;(this.triggerEl ?? this.defaultSlotted)?.focus() } /** Called by the root when the open value changes. */ setExpanded(expanded: boolean, motion?: string) { this.motion = motion if (expanded) { this.expanded = true this.mounted = true return } if (!this.expanded) return this.expanded = false this.updateComplete.then(() => { const viewport = this.renderRoot.querySelector('[data-slot=navigation-menu-viewport]') afterAnimations(motion ? this.contentEl : (viewport ?? this.contentEl), () => { if (!this.expanded) this.mounted = false }) }) } protected updated(changed: Map<string, unknown>) { if (changed.has('mounted') && !isServer) { this.ro?.disconnect() const content = this.contentEl if (this.mounted && content) { this.ro = new ResizeObserver(() => this.measure()) this.ro.observe(content) this.measure() } } } // Written straight to the viewport's style: the size is measured after // render, and a reactive property set in updated() would schedule a second // update. (The viewport is only rendered while mounted, so the vars never go stale.) private measure() { const c = this.contentEl const viewport = this.renderRoot.querySelector<HTMLElement>('[data-slot=navigation-menu-viewport]') if (!c || !viewport) return viewport.style.setProperty('--radix-navigation-menu-viewport-width', `${c.offsetWidth}px`) viewport.style.setProperty('--radix-navigation-menu-viewport-height', `${c.offsetHeight}px`) } private onTriggerPointerEnter(e: PointerEvent) { if (e.pointerType === 'mouse') this.root?.onTriggerEnter(this) } private onPointerLeave(e: PointerEvent) { if (e.pointerType === 'mouse') this.root?.startClose() } private onContentPointerEnter(e: PointerEvent) { if (e.pointerType === 'mouse') this.root?.cancelClose() } private onTriggerKeyDown(e: KeyboardEvent) { if (e.key !== 'ArrowDown') return e.preventDefault() const focusFirst = () => { const slot = this.renderRoot.querySelector<HTMLSlotElement>('slot[name="content"]') const first = slot ?.assignedElements({ flatten: true }) .flatMap((el) => [el, ...el.querySelectorAll('*')]) .find((el): el is HTMLElement => el instanceof HTMLElement && el.matches('a[href], button, [tabindex]:not([tabindex="-1"]), input, select, textarea, uip-navigation-menu-link')) first?.focus() } const root = this.root if (!this.expanded) root?.toggle(this) // The root re-renders first (it calls setExpanded), then this item mounts the panel. Promise.resolve(root?.updateComplete).then(() => this.updateComplete).then(focusFirst) } private onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape' && this.expanded) { e.preventDefault() this.root?.setValue('') this.triggerEl?.focus() } } // A click on a link inside the panel closes the menu (Radix onSelect). private onContentClick(e: MouseEvent) { if (e.composedPath().some((n) => n instanceof HTMLAnchorElement)) this.root?.setValue('') } private renderContent() { const id = `${this.idBase}-content` return html`<div id=${id} aria-labelledby=${`${this.idBase}-trigger`} data-uipkge="" data-slot="navigation-menu-content" data-state=${this.expanded ? 'open' : 'closed'} data-motion=${this.motion ?? nothing} data-orientation="horizontal" class=${contentClasses} @pointerenter=${this.onContentPointerEnter} @pointerleave=${this.onPointerLeave} @click=${this.onContentClick} > <slot name="content"></slot> </div>` } render() { const viewport = this.root?.viewport ?? true const state = this.expanded ? 'open' : 'closed' const trigger = this.hasTrigger ? html`<button id=${`${this.idBase}-trigger`} type="button" aria-expanded=${this.expanded ? 'true' : 'false'} aria-controls=${`${this.idBase}-content`} data-uipkge="" data-slot="navigation-menu-trigger" data-state=${state} class=${cn(navigationMenuTriggerStyle(), 'group')} @pointerenter=${this.onTriggerPointerEnter} @pointerleave=${this.onPointerLeave} @click=${() => this.root?.toggle(this)} @keydown=${this.onTriggerKeyDown} > <slot name="trigger"></slot>${icon(ChevronDown, 'chevron-down', chevronClasses)} </button>` : nothing return html`<div class="group/navigation-menu contents" data-viewport=${viewport ? 'true' : 'false'} @keydown=${this.onKeyDown}> <div part="base" class=${itemClasses}> ${trigger} <slot @slotchange=${() => this.requestUpdate()}></slot> ${this.hasTrigger && !viewport && this.mounted ? this.renderContent() : nothing} </div> ${this.hasTrigger && viewport && this.mounted ? html`<div class=${viewportWrapperClasses}> <div data-uipkge="" data-slot="navigation-menu-viewport" data-state=${state} data-orientation="horizontal" class=${viewportClasses} @pointerenter=${this.onContentPointerEnter} @pointerleave=${this.onPointerLeave} > ${this.renderContent()} </div> </div>` : nothing} </div>` } } /** * <uip-navigation-menu-link> — NavigationMenuLink: an <a> with React's link * classes. `trigger-style` merges `navigationMenuTriggerStyle()` (what the * React stories pass as className for top-level links); `active` sets * data-active. Emits `select` (cancelable) on click. */ export class UipNavigationMenuLink extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } static styles = [tailwind, css`:host { display: contents; }`] static properties: PropertyDeclarations = { href: {}, target: {}, active: { type: Boolean, reflect: true }, triggerStyle: { type: Boolean, attribute: 'trigger-style' }, } href?: string target?: string active = false triggerStyle = false constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') } private onClick(e: MouseEvent) { const ok = this.dispatchEvent(new CustomEvent('select', { bubbles: true, composed: true, cancelable: true })) if (!ok) e.preventDefault() } render() { return html`<a href=${this.href ?? nothing} target=${this.target ?? nothing} aria-current=${this.active ? 'page' : nothing} ?data-active=${this.active} data-uipkge="" data-slot="navigation-menu-link" class=${cn(linkClasses, this.triggerStyle && navigationMenuTriggerStyle())} @click=${this.onClick} ><slot></slot ></a>` } } customElements.get('uip-navigation-menu') || customElements.define('uip-navigation-menu', UipNavigationMenu) customElements.get('uip-navigation-menu-item') || customElements.define('uip-navigation-menu-item', UipNavigationMenuItem) customElements.get('uip-navigation-menu-link') || customElements.define('uip-navigation-menu-link', UipNavigationMenuLink) declare global { interface HTMLElementTagNameMap { 'uip-navigation-menu': UipNavigationMenu 'uip-navigation-menu-item': UipNavigationMenuItem 'uip-navigation-menu-link': UipNavigationMenuLink } }src/uip/components/navigation-menu/navigation-menu.variants.ts0.9 kB
import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts`) so consuming Vue SFCs can import without creating a circular * dependency through the index. See card.variants.ts for the canonical * example + the SSR symptom that motivated the split. */ export const navigationMenuTriggerStyle = cva( 'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1', )
Raw manifest:https://uipkge.dev/r/lit/navigation-menu.json