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

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
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
itemsNavigationMenuItem[] | NavigationMenuItem[][][]
orientation'horizontal' | 'vertical''horizontal'
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>-