NavigationMenu
A navigation list that works as a horizontal mega-menu or a vertical set of disclosures. It
renders a <nav> landmark and real anchors, detects the active route on its own, and can collapse into an icon rail
or a mobile drawer.
Basic Usage
Pass an items array. Entries with an href render
as real anchors, which keeps the menu crawlable and lets middle-click and open-in-new-tab work.
<script lang="ts">
import { NavigationMenu } from 'sv5ui';
import type { NavigationMenuItem } from 'sv5ui';
const items: NavigationMenuItem[] = [
{ label: 'Home', icon: 'lucide:house', href: '/' },
{ label: 'Docs', icon: 'lucide:book-open', href: '/docs' },
{ label: 'Pricing', icon: 'lucide:credit-card', href: '/pricing' },
{ label: 'Changelog', icon: 'lucide:history', href: '/changelog' }
];
</script>
<!-- The root renders a <nav> landmark and real <a href> links -->
<NavigationMenu {items} />Mega Menu
Give an item children and it becomes a dropdown trigger. A child that has children of its own turns into a labeled
column, which is how you build a mega-menu. Nesting stops at one level. Hover Product or Resources to open the panel.
<!-- An item with children becomes a dropdown trigger.
A child with its own children becomes a labeled column. -->
const items: NavigationMenuItem[] = [
{
label: 'Product',
children: [
{
label: 'Platform',
children: [
{ label: 'Analytics', description: 'Track everything that matters', icon: 'lucide:chart-line', href: '/analytics' },
{ label: 'Automations', description: 'Rules that run themselves', icon: 'lucide:zap', href: '/automations' }
]
},
{
label: 'Developers',
children: [
{ label: 'API', description: 'REST and webhooks', icon: 'lucide:code', href: '/api' },
{ label: 'SDKs', description: 'Six languages, one shape', icon: 'lucide:package', href: '/sdks' }
]
}
]
},
{ label: 'Pricing', href: '/pricing' }
];
<NavigationMenu {items} />Content Orientation
contentOrientation controls how the mega-menu panel lays out its columns: horizontal , the default, gives a fixed two-column panel, while vertical stacks the columns and lets the panel size itself to its content.
vertical
<!-- horizontal (default): a fixed two-column panel (sm:w-128) -->
<NavigationMenu {items} contentOrientation="horizontal" />
<!-- vertical: columns stack and the panel sizes to its content -->
<NavigationMenu {items} contentOrientation="vertical" />
<!-- Only applies to orientation="horizontal". Vertical menus use
inline disclosures, which have no panel to lay out. -->Groups
Pass an array of arrays to render separated groups. Vertically a separator is drawn between them.
<!-- Pass an array of arrays to render separated groups.
Horizontally they are spaced apart, vertically a
separator is drawn between them. -->
const items: NavigationMenuItem[][] = [
[
{ label: 'Dashboard', icon: 'lucide:layout-dashboard', href: '/' },
{ label: 'Projects', icon: 'lucide:folder', href: '/projects' }
],
[
{ label: 'Settings', icon: 'lucide:settings', href: '/settings' },
{ label: 'Help', icon: 'lucide:circle-help', href: '/help' }
]
];
<NavigationMenu {items} orientation="vertical" />Variants and Highlight
pill fills the active item, link keeps it flat. The highlight bar slides to the active route and is ignored for pill,
whose background already marks it.
<!-- pill (default) fills the active item's background -->
<NavigationMenu {items} variant="pill" color="primary" />
<!-- link keeps the item flat and only changes the text color -->
<NavigationMenu {items} variant="link" color="primary" />
<!-- highlight adds a sliding bar that follows the active route.
It is ignored for pill, whose filled background already
marks the active item. -->
<NavigationMenu {items} variant="link" highlight />Active Route
Active state comes from the current route. With exact on, the default, only
an identical path matches; turn it off so /docs stays active on /docs/components/button. This demo runs against the page you are on, so only
the exact: false entry lights up.
<!-- Active state is detected from the current route. With exact
(the default) /docs is only active on /docs itself. With
exact={false} it also stays active on /docs/components/button,
which is what you usually want for a sidebar.
aria-current="page" remains exact-only either way. -->
const items: NavigationMenuItem[] = [
{ label: '/docs (exact)', href: '/docs' },
{ label: '/docs (exact: false)', href: '/docs', exact: false },
{ label: '/pricing', href: '/pricing' }
];
<NavigationMenu {items} variant="link" highlight />
<!-- Set exact once for the whole menu -->
<NavigationMenu {items} exact={false} />
<!-- Or bypass route detection entirely and drive it yourself -->
const items: NavigationMenuItem[] = [
{ label: 'Blog', href: '/blog', active: isBlogSection }
];Vertical
Vertically, items with children become inline disclosures rather than dropdowns, and type decides how many stay open. collapsible={false} disables the triggers and freezes the current state, so pair it with defaultValue listing every section you want pinned open.
type: single
collapsible: false + defaultValue
<!-- Vertically, items with children become inline disclosures
instead of dropdowns. type controls how many stay open. -->
<NavigationMenu
{items}
orientation="vertical"
type="single"
defaultValue="components"
/>
<!-- type="multiple" (the default) lets several stay open -->
<NavigationMenu {items} orientation="vertical" bind:value={openSections} />
<!-- collapsible={false} disables the triggers and freezes whatever
is open right now. It does NOT open anything by itself, so a
section that was closed stays closed behind a dead trigger.
Pass defaultValue (or bind:value) with every key you want pinned. -->
<NavigationMenu
{items}
orientation="vertical"
collapsible={false}
defaultValue={['components', 'hooks', 'theming']}
/>Icons, Badges and Labels
Items accept an icon or an avatar,
a badge and a status chip.
Use type: 'label' for a non-interactive section header, and external links get their target, rel and trailing
icon for free.
const items: NavigationMenuItem[] = [
// type: 'label' renders a non-interactive section header
{ label: 'Workspace', type: 'label' },
{ label: 'Inbox', icon: 'lucide:inbox', badge: 12, href: '/inbox' },
// badge accepts BadgeProps for full control
{ label: 'Deploys', icon: 'lucide:rocket', badge: { label: 'beta', color: 'warning', variant: 'soft' }, href: '/deploys' },
// chip overlays a status dot on the leading icon or avatar
{ label: 'Status', icon: 'lucide:activity', chip: { color: 'success' }, href: '/status' },
// avatar takes precedence over icon
{ label: 'Ana Rivera', avatar: { src: '/avatars/ana.jpg', alt: 'Ana Rivera' }, href: '/team/ana' },
// External links get target, rel and an external icon automatically
{ label: 'GitHub', icon: 'lucide:github', href: 'https://github.com', target: '_blank' },
{ label: 'Archived', icon: 'lucide:archive', disabled: true }
];Stacked
stacked puts
the icon above a small label. Horizontally that gives you a mobile bottom tab bar with equal-width
tabs, vertically a compact rail that keeps its labels.
<!-- stacked puts the icon above a small label.
Horizontally that is a mobile bottom tab bar with
equal-width tabs. -->
<NavigationMenu {items} stacked class="w-full" />
<!-- Vertically it is a compact rail that keeps the labels -->
<NavigationMenu {items} stacked orientation="vertical" />Collapsed Rail
Set collapsed to drop the labels and keep an icon rail. Enable tooltip so labels resurface on hover, and popover so
items with children open a flyout instead of expanding in place. Both accept full Tooltip and
Popover props, and per-item settings override them.
<!-- collapsed hides the labels and leaves an icon rail.
Labels resurface through a tooltip, and items with
children open a popover flyout instead of expanding. -->
<NavigationMenu
{items}
orientation="vertical"
collapsed
tooltip
popover
/>
<!-- Both accept full TooltipProps / PopoverProps -->
<NavigationMenu
{items}
orientation="vertical"
collapsed
tooltip={{ delayDuration: 200 }}
/>
<!-- Per item overrides win over the menu-level defaults -->
const items: NavigationMenuItem[] = [
{ label: 'Search', icon: 'lucide:search', tooltip: { content: { side: 'right' } } }
];Mobile Drawer
With drawer, the menu becomes a toggle button and a Drawer below mobileBreakpoint. Nothing else renders inline, so put it in an app bar and
pair it with orientation="vertical". The demo uses a query that always
matches so the drawer is reachable without resizing.
<!-- A drawer holds the whole navigation, so give it real groups,
labels and nested children rather than a handful of links. -->
const items: NavigationMenuItem[][] = [
[
{ label: 'Workspace', type: 'label' },
{ label: 'Dashboard', icon: 'lucide:layout-dashboard', href: '/' },
{
label: 'Projects',
icon: 'lucide:folder',
defaultOpen: true,
children: [
{ label: 'Active', href: '/projects/active' },
{ label: 'Archived', href: '/projects/archived' },
{ label: 'Templates', href: '/projects/templates' }
]
},
{
label: 'Deploys',
icon: 'lucide:rocket',
badge: 3,
children: [
{ label: 'Production', href: '/deploys/production' },
{ label: 'Preview', href: '/deploys/preview' },
{ label: 'Build logs', href: '/deploys/logs' }
]
}
],
[
{ label: 'Resources', type: 'label' },
{
label: 'Documentation',
icon: 'lucide:book-open',
children: [
{ label: 'Getting started', href: '/docs' },
{ label: 'Components', href: '/docs/components' }
]
},
{ label: 'GitHub', icon: 'lucide:github', href: 'https://github.com', target: '_blank' }
],
[
{ label: 'Account', type: 'label' },
{
label: 'Settings',
icon: 'lucide:settings',
children: [
{ label: 'Profile', href: '/settings/profile' },
{ label: 'Billing', href: '/settings/billing' }
]
},
{ label: 'Help', icon: 'lucide:circle-help', href: '/help' }
]
];
<!-- Below mobileBreakpoint the component renders ONLY the toggle
button and the drawer, so give it a home in your app bar.
Pass orientation="vertical": the drawer always stacks its
contents, so declaring it keeps the inline menu a sidebar
instead of a row and the same list reads right in both states.
The breakpoint is a useMediaQuery, which is false on the server,
so the inline menu renders during SSR and swaps to the toggle on
hydration. A fixed row height keeps that swap from moving the page. -->
<header class="flex h-14 items-center gap-2 border-b px-2">
<NavigationMenu
{items}
orientation="vertical"
drawer
bind:drawerOpen
mobileBreakpoint="(max-width: 767px)"
drawerWidth="18rem"
drawerProps={{ direction: 'left' }}
/>
<span class="text-sm font-semibold">Acme Inc</span>
</header>
<!-- drawerWidth is a fixed CSS width on purpose: it stops the
panel reflowing when an inline section expands. -->
<!-- The demo uses a query that always matches, so the drawer is
reachable without resizing. Do not do this in an app: the menu
would never render inline. -->
<NavigationMenu {items} orientation="vertical" drawer mobileBreakpoint="(min-width: 0px)" />Open Behaviour
Dropdowns open on hover and on click. Tune the hover feel with delayDuration and skipDelayDuration, or restrict the trigger to one of the two. arrow points
a small arrow at the open trigger.
<!-- Dropdowns open on hover by default, with no delay -->
<NavigationMenu {items} delayDuration={150} skipDelayDuration={300} />
<!-- Click only -->
<NavigationMenu {items} disableHoverTrigger />
<!-- Hover only -->
<NavigationMenu {items} disableClickTrigger />
<!-- Point an arrow at the open trigger -->
<NavigationMenu {items} arrow />SEO
Hidden dropdown content is removed from the DOM by default. Pass unmountOnHide={false} to keep it mounted but hidden so crawlers can
follow every link in a mega-menu. The root is a <nav> landmark, which as can change.
<!-- Hidden dropdown content is removed from the DOM by default.
Set unmountOnHide={false} to keep it mounted but hidden so
crawlers can follow the links inside your mega-menu. -->
<NavigationMenu {items} unmountOnHide={false} />
<!-- Every entry with an href renders as a real <a href>,
and the root is a <nav> landmark. Change the element
with as when the menu is not the primary navigation. -->
<NavigationMenu {items} as="div" orientation="vertical" />Custom Rendering
Every region has a snippet. Note itemActions: it renders as a sibling of the anchor rather than inside it, so a button or dropdown in a
row stays valid HTML. It is vertical only, and appears on hover or keyboard focus.
<!-- Item snippets receive { item, index, active, open } -->
<NavigationMenu {items} orientation="vertical">
{#snippet itemTrailing({ item, active })}
{#if item.count}
<Badge label={String(item.count)} size="sm" variant={active ? 'solid' : 'soft'} />
{/if}
{/snippet}
<!-- itemActions renders as a sibling of the anchor, never
nested inside it, so buttons and menus stay valid HTML.
Vertical only. -->
{#snippet itemActions({ item })}
<DropdownMenu items={rowActions(item)}>
{#snippet trigger({ props })}
<Button {...props} icon="lucide:ellipsis" variant="ghost" size="xs" />
{/snippet}
</DropdownMenu>
{/snippet}
</NavigationMenu>
<!-- itemContent replaces a dropdown's entire panel -->
<NavigationMenu {items}>
{#snippet itemContent({ item })}
<div class="grid w-lg grid-cols-2 gap-4 p-4">
<FeaturedCard {item} />
<LinkList links={item.children} />
</div>
{/snippet}
</NavigationMenu>
<!-- listLeading / listTrailing bracket the whole list -->
<NavigationMenu {items}>
{#snippet listTrailing()}
<Button label="Sign in" size="sm" />
{/snippet}
</NavigationMenu>NavigationMenuItem
Shape of each top-level entry.
| Field | Type | Default |
|---|---|---|
label | string | - |
icon | string | - |
avatar | AvatarProps | - |
badge | string | number | BadgeProps | - |
chip | boolean | ChipProps | - |
type | 'label' | 'trigger' | 'link' | 'link' |
href | string | - |
target | string | - |
children | NavigationMenuChildItem[] | - |
value | string | index |
defaultOpen | boolean | false |
open | boolean | - |
active | boolean | - |
exact | boolean | - |
disabled | boolean | false |
tooltip | boolean | TooltipProps | - |
popover | boolean | PopoverProps | - |
trailingIcon | string | - |
onSelect | (event: Event) => void | - |
class | string | - |
ui | Record<Slot, Class> | - |
NavigationMenuChildItem
Shape of each entry inside an item's children.
| Field | Type | Default |
|---|---|---|
label | string | - |
description | string | - |
icon | string | - |
href | string | - |
target | string | - |
active | boolean | - |
exact | boolean | true |
onSelect | (event: Event) => void | - |
children | NavigationMenuChildItem[] | - |
class | string | - |
Snippets
| Snippet | Description |
|---|---|
item | Full custom row renderer, receives { item, index, active, open } |
itemLeading | Leading content. Falls back to the avatar or icon |
itemLabel | Label renderer. Falls back to item[labelKey] |
itemTrailing | Trailing content inside the link. Falls back to the badge and chevron |
itemActions | Interactive row actions rendered beside the anchor, not inside it. Vertical only |
itemContent | Custom dropdown or mega-menu panel, receives { item, index } |
listLeading | Content before the list |
listTrailing | Content after the list |
UI Slots
Use the ui prop to override classes.
| Slot | Description |
|---|---|
root / list / item | The nav landmark, the ul and each li |
link / linkLabel / linkLeadingIcon / linkLeadingAvatar | The item row and its leading content |
linkTrailing / linkTrailingBadge / linkTrailingIcon / linkBadgeDot | The trailing region of an item row |
label / separator | A type: label header and the divider between groups |
highlight | The sliding active-route bar |
childList / childItem / childLink / childLinkIcon / childLinkLabel / childLinkDescription | The dropdown entries |
childGroup / childGroupLabel / childGroupList | A labeled mega-menu column |
content / viewport / viewportWrapper / indicator / arrow | The dropdown panel, its animated viewport and the trigger arrow |
itemActions / labelActions | The itemActions region on a row and on a label header |
toggle / childTrigger | The mobile drawer trigger and a nested disclosure trigger |
Props
| Prop | Type | Default |
|---|---|---|
items | NavigationMenuItem[] | NavigationMenuItem[][] | [] |
orientation | 'horizontal' | 'vertical' | 'horizontal' |
variant | 'pill' | 'link' | 'pill' |
color | ColorType | 'primary' |
highlight | boolean | false |
stacked | boolean | false |
type | 'single' | 'multiple' | 'multiple' |
value | string | string[] | - |
defaultValue | string | string[] | - |
onValueChange | (value) => void | - |
collapsed | boolean | false |
collapsible | boolean | true |
contentOrientation | 'horizontal' | 'vertical' | 'horizontal' |
exact | boolean | true |
tooltip | boolean | TooltipProps | false |
popover | boolean | PopoverProps | false |
trailingIcon | string | 'lucide:chevron-down' |
externalIcon | boolean | string | true |
arrow | boolean | false |
delayDuration | number | 0 |
skipDelayDuration | number | - |
disableClickTrigger | boolean | false |
disableHoverTrigger | boolean | false |
unmountOnHide | boolean | true |
drawer | boolean | false |
drawerOpen | boolean | false |
mobileBreakpoint | string | '(max-width: 767px)' |
drawerWidth | string | 'min(20rem, 80vw)' |
drawerProps | Partial<DrawerProps> | - |
labelKey | string | 'label' |
disabled | boolean | false |
as | string | 'nav' |
ref | HTMLElement | null | null |
class | string | - |
ui | Record<Slot, Class> | - |