Installation
Install the dependencies
pnpm add cn class-variance-authority github:sveltery/baseCopy the component
Copy the following files into src/lib/components/ui/navigation-menu.
<script lang="ts" generics="Value = unknown">
import type { NavigationMenuPositionerProps, NavigationMenuRootProps } from '@sveltery/base/navigation-menu';
import { NavigationMenu as Primitive } from '@sveltery/base/navigation-menu';
import { cn } from 'cn';
import NavigationMenuPositioner from './NavigationMenuPositioner.svelte';
let { value = $bindable(null), align = 'start', children, class: classProp, ...props }: NavigationMenuRootProps<Value> & Pick<NavigationMenuPositionerProps, 'align'> = $props();
let root: ReturnType<typeof Primitive.Root>;
export function close() {
root.close();
}
export function unmount() {
root.unmount();
}
</script>
<Primitive.Root bind:this={root} data-slot="navigation-menu" bind:value {...props} class={cn('group/navigation-menu relative flex max-w-max flex-1 items-center justify-center', classProp)}>
{@render children?.()}
<NavigationMenuPositioner {align} />
</Primitive.Root><script lang="ts">
import type { ComponentProps } from 'svelte';
import { NavigationMenu as Primitive } from '@sveltery/base/navigation-menu';
import { cn } from 'cn';
let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Content> = $props();
</script>
<Primitive.Content data-slot="navigation-menu-content" {...props} class={cn('data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:ring-foreground/10 p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:duration-300 data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] h-full w-auto transition-[opacity,transform,translate] duration-[0.35s] data-ending-style:opacity-0 data-starting-style:opacity-0 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none', classProp)}>
{@render children?.()}
</Primitive.Content><script lang="ts">
import type { ComponentProps } from 'svelte';
import { NavigationMenu as Primitive } from '@sveltery/base/navigation-menu';
import { cn } from 'cn';
let { class: classProp, ...props }: Omit<ComponentProps<typeof Primitive.Icon>, 'children'> = $props();
</script>
<Primitive.Icon data-slot="navigation-menu-indicator" {...props} class={cn('data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden', classProp)}>
<div class="bg-border rounded-tl-sm shadow-md relative top-[60%] h-2 w-2 rotate-45"></div>
</Primitive.Icon><script lang="ts">
import type { ComponentProps } from 'svelte';
import { NavigationMenu as Primitive } from '@sveltery/base/navigation-menu';
import { cn } from 'cn';
let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Item> = $props();
</script>
<Primitive.Item data-slot="navigation-menu-item" {...props} class={cn('relative', classProp)}>
{@render children?.()}
</Primitive.Item><script lang="ts">
import type { ComponentProps } from 'svelte';
import { NavigationMenu as Primitive } from '@sveltery/base/navigation-menu';
import { cn } from 'cn';
let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Link> = $props();
</script>
<Primitive.Link data-slot="navigation-menu-link" {children} {...props} class={cn("data-active:focus:bg-muted data-active:hover:bg-muted data-active:bg-muted/50 focus-visible:ring-ring/50 hover:bg-muted focus:bg-muted flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none focus-visible:ring-3 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md [&_svg:not([class*='size-'])]:size-4", classProp)} /><script lang="ts">
import type { ComponentProps } from 'svelte';
import { NavigationMenu as Primitive } from '@sveltery/base/navigation-menu';
import { cn } from 'cn';
let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.List> = $props();
</script>
<Primitive.List data-slot="navigation-menu-list" {...props} class={cn('gap-0 group flex flex-1 list-none items-center justify-center', classProp)}>
{@render children?.()}
</Primitive.List><script lang="ts">
import type { ComponentProps } from 'svelte';
import { NavigationMenu as Primitive } from '@sveltery/base/navigation-menu';
import { cn } from 'cn';
let { class: classProp, side = 'bottom', sideOffset = 8, align = 'start', alignOffset = 0, ...props }: ComponentProps<typeof Primitive.Positioner> = $props();
</script>
<Primitive.Portal>
<Primitive.Positioner {side} {sideOffset} {align} {alignOffset} {...props} class={cn('ease-[cubic-bezier(0.22,1,0.36,1)] data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0 isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] data-instant:transition-none', classProp)}>
<Primitive.Popup class="bg-popover text-popover-foreground ring-foreground/10 rounded-lg shadow ring-1 outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:scale-90 data-starting-style:opacity-0 data-[ending-style]:easing-[ease] xs:w-(--popup-width) relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)]">
<Primitive.Viewport class="relative size-full overflow-hidden" />
</Primitive.Popup>
</Primitive.Positioner>
</Primitive.Portal><script lang="ts">
import type { ComponentProps } from 'svelte';
import { NavigationMenu as Primitive } from '@sveltery/base/navigation-menu';
import { cn } from 'cn';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { navigationMenuTriggerStyle } from './variants.js';
let { children, class: classProp, ...props }: ComponentProps<typeof Primitive.Trigger> = $props();
</script>
<Primitive.Trigger data-slot="navigation-menu-trigger" {...props} class={cn(navigationMenuTriggerStyle(), 'group', classProp)}>
{@render children?.()} <ChevronDownIcon class="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
</Primitive.Trigger>// Adapted from shadcn-ui/ui d75a96ab; MIT: packages/ui/THIRD_PARTY_NOTICES.md.
import { cva } from 'class-variance-authority';
export const navigationMenuTriggerStyle = cva('hover:bg-muted focus:bg-muted data-popup-open:focus:bg-muted focus-visible:ring-ring/50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all focus-visible:ring-3 focus-visible:outline-1 disabled:opacity-50 group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center outline-none disabled:pointer-events-none');export { default as NavigationMenu } from './NavigationMenu.svelte';
export { default as NavigationMenuList } from './NavigationMenuList.svelte';
export { default as NavigationMenuItem } from './NavigationMenuItem.svelte';
export { default as NavigationMenuTrigger } from './NavigationMenuTrigger.svelte';
export { default as NavigationMenuContent } from './NavigationMenuContent.svelte';
export { default as NavigationMenuLink } from './NavigationMenuLink.svelte';
export { default as NavigationMenuIndicator } from './NavigationMenuIndicator.svelte';
export { default as NavigationMenuPositioner } from './NavigationMenuPositioner.svelte';
export { navigationMenuTriggerStyle } from './variants.js';Update the import paths
Change the imports to match your project setup.
Usage
<script lang="ts">
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger } from '$lib/components/ui/navigation-menu';
</script><NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Item One</NavigationMenuTrigger>
<NavigationMenuContent>
<NavigationMenuLink href="/docs">Link</NavigationMenuLink>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>Hovering or clicking a trigger opens its content in a shared popup below the menu. Moving to another trigger swaps the content, and Escape or a press outside closes it. The arrow keys move focus between triggers and links.
Link
NavigationMenuLink renders an <a>. Pass active to mark the current page, which sets aria-current="page" and data-active. Pass closeOnClick to close the menu when the link is clicked. To use another element, such as a router link component, pass a render snippet and spread its props onto that element.
Use navigationMenuTriggerStyle to style a top-level link like a trigger.
<script lang="ts">
import { NavigationMenuItem, NavigationMenuLink, navigationMenuTriggerStyle } from '$lib/components/ui/navigation-menu';
</script>
<NavigationMenuItem>
<NavigationMenuLink href="/docs" class={navigationMenuTriggerStyle()}>Documentation</NavigationMenuLink>
</NavigationMenuItem>Value
Bind value to read or change the open item from outside. Give each NavigationMenuItem a value. A null value closes the menu.
<script lang="ts">
let value = $state<string | null>(null);
</script>
<NavigationMenu bind:value>
<NavigationMenuList>
<NavigationMenuItem value="docs">…</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>Bind the menu with bind:this to call its close export, which closes the open item, or its unmount export, which unmounts a popup that eventDetails.preventUnmountOnClose() in onValueChange kept mounted.
API reference
NavigationMenu is styled on top of the Sveltery Base NavigationMenu primitive, which owns its hover intent, keyboard navigation, focus handling and dismissal. NavigationMenu renders the Base portal, positioner, popup and viewport through NavigationMenuPositioner, and takes the positioner's align prop. NavigationMenuIndicator renders the Base Icon part with an arrow inside, and goes inside a NavigationMenuItem. See the Base UI Navigation Menu documentation for the behavior it ports.