Navigation

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. -->

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.

Acme Inc
Use the menu button to slide the navigation in from the left.
<!-- 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.

FieldTypeDefault
labelstring-
iconstring-
avatarAvatarProps-
badgestring | number | BadgeProps-
chipboolean | ChipProps-
type'label' | 'trigger' | 'link''link'
hrefstring-
targetstring-
childrenNavigationMenuChildItem[]-
valuestringindex
defaultOpenbooleanfalse
openboolean-
activeboolean-
exactboolean-
disabledbooleanfalse
tooltipboolean | TooltipProps-
popoverboolean | PopoverProps-
trailingIconstring-
onSelect(event: Event) => void-
classstring-
uiRecord<Slot, Class>-

NavigationMenuChildItem

Shape of each entry inside an item's children.

FieldTypeDefault
labelstring-
descriptionstring-
iconstring-
hrefstring-
targetstring-
activeboolean-
exactbooleantrue
onSelect(event: Event) => void-
childrenNavigationMenuChildItem[]-
classstring-

Snippets

SnippetDescription
itemFull custom row renderer, receives { item, index, active, open }
itemLeadingLeading content. Falls back to the avatar or icon
itemLabelLabel renderer. Falls back to item[labelKey]
itemTrailingTrailing content inside the link. Falls back to the badge and chevron
itemActionsInteractive row actions rendered beside the anchor, not inside it. Vertical only
itemContentCustom dropdown or mega-menu panel, receives { item, index }
listLeadingContent before the list
listTrailingContent after the list

UI Slots

Use the ui prop to override classes.

SlotDescription
root / list / itemThe nav landmark, the ul and each li
scrollThe ScrollArea wrapping a horizontal list. The list itself no longer owns the overflow
link / linkLabel / linkLeadingIcon / linkLeadingAvatarThe item row and its leading content
linkTrailing / linkTrailingBadge / linkTrailingIcon / linkBadgeDotThe trailing region of an item row
label / separatorA type: label header and the divider between groups
highlightThe sliding active-route bar
childList / childItem / childLink / childLinkIcon / childLinkLabel / childLinkDescriptionThe dropdown entries
childGroup / childGroupLabel / childGroupListA labeled mega-menu column
content / viewport / viewportWrapper / indicator / arrowThe dropdown panel, its animated viewport and the trigger arrow
itemActions / labelActionsThe itemActions region on a row and on a label header
toggle / childTriggerThe mobile drawer trigger and a nested disclosure trigger

Props

PropTypeDefault
scrollAreaScrollAreaOptions-
itemsNavigationMenuItem[] | NavigationMenuItem[][][]
orientation'horizontal' | 'vertical''horizontal'
align'start' | 'center' | 'end''center'
variant'pill' | 'link''pill'
colorColorType'primary'
highlightbooleanfalse
stackedbooleanfalse
type'single' | 'multiple''multiple'
valuestring | string[]-
defaultValuestring | string[]-
onValueChange(value) => void-
collapsedbooleanfalse
collapsiblebooleantrue
contentOrientation'horizontal' | 'vertical''horizontal'
exactbooleantrue
tooltipboolean | TooltipPropsfalse
popoverboolean | PopoverPropsfalse
trailingIconstring'lucide:chevron-down'
externalIconboolean | stringtrue
arrowbooleanfalse
delayDurationnumber0
skipDelayDurationnumber-
disableClickTriggerbooleanfalse
disableHoverTriggerbooleanfalse
unmountOnHidebooleantrue
drawerbooleanfalse
drawerOpenbooleanfalse
mobileBreakpointstring'(max-width: 767px)'
drawerWidthstring'min(20rem, 80vw)'
drawerPropsPartial<DrawerProps>-
labelKeystring'label'
disabledbooleanfalse
asstring'nav'
refHTMLElement | nullnull
classstring-
uiRecord<Slot, Class>-