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.
align prop places it at the trigger's start, centre or end. The 8px gap under the items is now reserved
only while the list actually overflows.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. -->Dropdown Alignment
The dropdown opens under the trigger that was activated. align decides which of its edges lines up: start, center (the default) or end. The
result is clamped to the menu, so a wide panel under the last trigger still stays on screen
instead of hanging off the edge.
That clamp is worth knowing before reaching for the prop: a panel wider than the space
beside its trigger has nowhere to go, so all three values collapse to the same edge. The
mega menu further up is 512px wide inside a 662px menu, and center and end both
land flush left there. The dropdowns below pass contentOrientation="vertical", which swaps the 512px two-column panel for a 240px list, and at that width all three
alignments land in different places.
align="start"
align="center"
align="end"
<!-- Which edge of the dropdown lines up with the trigger. The result is clamped
to the menu, so a wide panel under the last trigger stays on screen. -->
<!-- contentOrientation="vertical" keeps the panel narrow (min 240px). The default
horizontal content is 512px wide, which is wider than the space beside most
triggers, so it clamps to the menu edge and every alignment looks the same. -->
<NavigationMenu items={items} align="start" contentOrientation="vertical" />
<NavigationMenu items={items} align="center" contentOrientation="vertical" />
<NavigationMenu items={items} align="end" contentOrientation="vertical" />
<!-- A panel wider than the space beside its trigger clamps to the menu edge, so all
three values land in the same place there. Narrow dropdowns show the difference. -->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 |
scroll | The ScrollArea wrapping a horizontal list. The list itself no longer owns the overflow |
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 |
|---|---|---|
scrollArea | ScrollAreaOptions | - |
items | NavigationMenuItem[] | NavigationMenuItem[][] | [] |
orientation | 'horizontal' | 'vertical' | 'horizontal' |
align | 'start' | 'center' | 'end' | 'center' |
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> | - |