Skip to contentVibraUI
Navigation & layout

Nav config

A serializable navigation description plus pure helpers for active-item and breadcrumb lookup, and for joining two dashboards' sidebars into one.

Plain data and pure functions — no React, no hooks — so a NavConfig can be written in a server module or a template manifest and still cross into a client component. Icons are named (NavIconName), not components; app-shell resolves them through its own NAV_ICONS map. findNavItem treats an item's href as a word-boundary prefix (href itself, or href + "/"), so "/settings" matches "/settings/security" but never a hyphenated sibling route like "/settings-billing". An exact item drops out of prefix matching entirely: it matches only its own pathname, and a sub-path with no dedicated item of its own then matches nothing at all. When two items both match, the one with the longer href wins; currentHref applies the same rule to a flat list of hrefs, for a bar of plain links such as a marketing navbar. mergeNav puts two installed dashboards under one sidebar — mergeNav(ecommerceNav, saasNav) keeps the store's brand, both dashboards' groups in order and one Settings in the footer.

Install

npx shadcn@latest add @vibra/nav-config

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
NavIconName"layout-dashboard" | "users" | "shopping-cart" | "receipt" | "credit-card" | "settings" | "bell" | "inbox" | "calendar" | "activity" | "shield" | "key" | "plug" | "chart-line" | "file-text" | "life-buoy" | "server" | "sparkles" | "package" | "megaphone" | "git-branch" | "alert-triangle" | "list" | "home"—The icon names app-shell's NAV_ICONS map resolves to lucide components.
NavItem.titlestring—The label shown in the sidebar, breadcrumb trail, and command palette.
NavItem.hrefstring—The route this item points to, and the value findNavItem matches against.
NavItem.iconNavIconName—Optional leading icon, by name.
NavItem.badgestring | number—A trailing count or tag, e.g. an unread total or "New".
NavItem.itemsNavItem[]—One level of nested items, e.g. a settings page's sub-sections.
NavItem.exactbooleanfalseMatches pathname only exactly, never as a prefix of a deeper route.
NavGroup.labelstring—An optional heading rendered above the group's items.
NavGroup.itemsNavItem[]—The items in this group, in display order.
NavBrand.namestring—The product or workspace name shown beside the mark.
NavBrand.initialstring—A one- or two-character mark shown when there is no logo image.
NavBrand.hrefstring—Where the brand links to, and the root of every breadcrumb trail.
NavBrand.captionstring—A small line under the name, e.g. a plan or environment label.
NavConfig.brandNavBrand—The mark at the top of the shell and the first breadcrumb.
NavConfig.groupsNavGroup[]—The sidebar's sections, in display and flattenNav order.
NavConfig.footerNavItem[]—Pinned links rendered under the groups, e.g. support or docs.
flattenNav(config: NavConfig) => NavItem[]—Every item depth-first — each item immediately followed by its own nested items, groups in order, footer last.
findNavItem(config: NavConfig, pathname: string) => NavItem | undefined—The item that best matches pathname; the longest matching href wins.
currentHref(hrefs: readonly string[], pathname: string | null | undefined) => string | undefined—The same rule over a flat list of hrefs — a marketing navbar's links: the longest href pathname is on or under, undefined when none.
breadcrumbsFor(config: NavConfig, pathname: string) => { title: string; href: string }[]—The trail from the brand to the matched item; just the brand when nothing matches.
titleFor(config: NavConfig, pathname: string) => string—The matched item's title, or the last path segment humanised, e.g. "/api-keys" → "Api keys".
mergeNav(...navs: NavConfig[]) => NavConfig—Several navs as one sidebar — two dashboards in one app: the first nav's brand, every group in order (a group with no label takes its nav's brand caption, or its name), and the footers with each href once, the first winning; a footer link to a route a merged tab already holds is dropped. One nav comes back as it was.

Dependencies

Registry

Source

lib/nav-config.ts
// A serializable navigation description plus pure helpers over it — no React,
// no hooks. Icons are referenced by name rather than component so a NavConfig
// can be written from a server module or a template manifest and still cross
// into a client component (app-shell resolves NavIconName through a
// NAV_ICONS map).

export type NavIconName =
  | "layout-dashboard"
  | "users"
  | "user-round"
  | "shopping-cart"
  | "receipt"
  | "credit-card"
  | "settings"
  | "bell"
  | "inbox"
  | "calendar"
  | "activity"
  | "shield"
  | "key"
  | "key-round"
  | "plug"
  | "chart-line"
  | "file-text"
  | "life-buoy"
  | "server"
  | "sparkles"
  | "package"
  | "megaphone"
  | "git-branch"
  | "alert-triangle"
  | "list"
  | "home"
  | "briefcase"
  | "building"
  | "graduation-cap"
  | "heart-pulse"
  | "coins"
  | "folder"
  | "sticky-note"
  | "message-circle"
  | "list-todo"
  | "bot"
  | "image"
  | "audio-lines"
  | "wallet"
  | "workflow"
  | "mail"
  | "message-square-heart"

export type NavItem = {
  title: string
  href: string
  icon?: NavIconName
  badge?: string | number
  items?: NavItem[] // one level of nesting
  exact?: boolean // match pathname exactly (default: prefix)
}

export type NavGroup = { label?: string; items: NavItem[] }

export type NavBrand = { name: string; initial: string; href: string; caption?: string }

export type NavConfig = { brand: NavBrand; groups: NavGroup[]; footer?: NavItem[] }

/**
 * Every item in `config`, depth-first: each item is immediately followed by
 * its own nested items, groups are visited in order, and the footer comes
 * last.
 */
export function flattenNav(config: NavConfig): NavItem[] {
  const result: NavItem[] = []
  const visit = (item: NavItem) => {
    result.push(item)
    item.items?.forEach(visit)
  }
  for (const group of config.groups) group.items.forEach(visit)
  config.footer?.forEach(visit)
  return result
}

// An href matches its own pathname or anything one full segment below it —
// "/settings" matches "/settings/security" but not "/settings-billing", a
// sibling route that merely shares a text prefix.
function isUnder(href: string, pathname: string): boolean {
  return pathname === href || pathname.startsWith(`${href}/`)
}

// An `exact` item matches only its own pathname; any other item, by isUnder.
function matchesPathname(item: NavItem, pathname: string): boolean {
  return item.exact ? pathname === item.href : isUnder(item.href, pathname)
}

/**
 * The item whose href best matches `pathname`. An `exact` item matches only
 * that exact pathname; every other item matches by prefix, and when more
 * than one item matches, the one with the longest href wins.
 */
export function findNavItem(config: NavConfig, pathname: string): NavItem | undefined {
  let best: NavItem | undefined
  for (const item of flattenNav(config)) {
    if (!matchesPathname(item, pathname)) continue
    if (!best || item.href.length >= best.href.length) best = item
  }
  return best
}

/**
 * The href in a flat list of links that `pathname` is on — `findNavItem`'s
 * rule without a NavConfig around it, for a bar of plain links such as a
 * marketing site's navbar: an href matches its own pathname or one full
 * segment and more below it, and the longest match wins, so on "/docs/api"
 * the list ["/docs", "/docs/api"] answers "/docs/api" and ["/docs", "/pricing"]
 * answers "/docs". Undefined when nothing matches or there is no pathname.
 */
export function currentHref(hrefs: readonly string[], pathname: string | null | undefined): string | undefined {
  if (!pathname) return undefined
  let best: string | undefined
  for (const href of hrefs) {
    if (!isUnder(href, pathname)) continue
    if (best === undefined || href.length >= best.length) best = href
  }
  return best
}

/**
 * The trail from the brand to the matched item: the brand, then the matched
 * item's group item, then the matched item itself when it is nested one
 * level down. Returns just the brand when nothing matches `pathname`.
 */
export function breadcrumbsFor(config: NavConfig, pathname: string): { title: string; href: string }[] {
  const trail: { title: string; href: string }[] = [{ title: config.brand.name, href: config.brand.href }]
  const match = findNavItem(config, pathname)
  if (!match) return trail

  const lists = [...config.groups.map((group) => group.items), config.footer ?? []]
  for (const items of lists) {
    for (const item of items) {
      if (item === match) return [...trail, { title: item.title, href: item.href }]
      const nested = item.items?.find((child) => child === match)
      if (nested) {
        return [...trail, { title: item.title, href: item.href }, { title: nested.title, href: nested.href }]
      }
    }
  }
  return trail
}

/**
 * The matched item's title, or the last pathname segment humanised: dashes
 * become spaces and only the first letter is capitalised, e.g. "/api-keys" →
 * "Api keys".
 */
export function titleFor(config: NavConfig, pathname: string): string {
  const match = findNavItem(config, pathname)
  if (match) return match.title

  const segment = pathname.split("/").filter(Boolean).pop() ?? ""
  const humanised = segment.replace(/-/g, " ")
  return humanised.charAt(0).toUpperCase() + humanised.slice(1)
}

/**
 * Several navs as one sidebar — two dashboards installed in one app. The first
 * nav's brand leads; every group follows in order, and a group with no label
 * of its own takes its nav's brand caption (its name, when it has none) so the
 * reader can tell whose tabs they are. The footers join with each href once,
 * the first claim winning — and a footer link to a route one of the merged
 * tabs already holds is dropped, since findNavItem gives a tie to the later
 * item and the tab would never read as current. One nav comes back as it was.
 */
export function mergeNav(...navs: NavConfig[]): NavConfig {
  const [first] = navs
  if (!first) throw new Error("mergeNav needs at least one nav to take the brand from.")
  if (navs.length === 1) return { ...first }

  const groups = navs.flatMap((nav) =>
    nav.groups.map((group) => (group.label ? group : { ...group, label: nav.brand.caption || nav.brand.name }))
  )
  const held = new Set(flattenNav({ brand: first.brand, groups }).map((item) => item.href))
  const footer = navs
    .flatMap((nav) => nav.footer ?? [])
    .filter((item) => {
      if (held.has(item.href)) return false
      held.add(item.href)
      return true
    })

  return { brand: first.brand, groups, ...(navs.some((nav) => nav.footer) ? { footer } : {}) }
}