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-configNeeds the @vibra registry in your components.json — set it up once.
Examples
import { breadcrumbsFor, titleFor, type NavConfig } from "@/lib/nav-config"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
// One config exercising every rule the helpers document: a plain item
// (Dashboard), a nested item under a normal parent (Customers > Segments),
// an `exact` parent with its own nested item (Settings > Security), and a
// footer link.
const NAV: NavConfig = {
brand: { name: "Northwind", initial: "N", href: "/" },
groups: [
{
label: "Workspace",
items: [
{ title: "Dashboard", href: "/dashboard", icon: "layout-dashboard" },
{
title: "Customers",
href: "/customers",
icon: "users",
items: [{ title: "Segments", href: "/customers/segments" }],
},
],
},
{
label: "Configuration",
items: [
{
title: "Settings",
href: "/settings",
icon: "settings",
exact: true,
items: [{ title: "Security", href: "/settings/security" }],
},
],
},
],
footer: [{ title: "Support", href: "/support", icon: "life-buoy" }],
}
// Three pathnames, three different rules: a dynamic sub-route falling back
// to its nearest ancestor, a nested match under an `exact` parent, and a
// route with no nav item at all.
const PATHNAMES = ["/customers/cus_1042", "/settings/security", "/api-keys"]
type Row = { pathname: string; title: string; breadcrumb: string }
const rows: Row[] = PATHNAMES.map((pathname) => ({
pathname,
title: titleFor(NAV, pathname),
breadcrumb: breadcrumbsFor(NAV, pathname)
.map((crumb) => crumb.title)
.join(" / "),
}))
const columns: SimpleTableColumn<Row>[] = [
{ key: "pathname", header: "Pathname", width: "13rem", className: "font-mono text-xs" },
{ key: "title", header: "titleFor()" },
{ key: "breadcrumb", header: "breadcrumbsFor()" },
]
export default function NavConfigDemo() {
return (
<SimpleTable
className="w-full"
columns={columns}
rows={rows}
rowKey="pathname"
caption="One NavConfig, three pathnames"
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| 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.title | string | — | The label shown in the sidebar, breadcrumb trail, and command palette. |
| NavItem.href | string | — | The route this item points to, and the value findNavItem matches against. |
| NavItem.icon | NavIconName | — | Optional leading icon, by name. |
| NavItem.badge | string | number | — | A trailing count or tag, e.g. an unread total or "New". |
| NavItem.items | NavItem[] | — | One level of nested items, e.g. a settings page's sub-sections. |
| NavItem.exact | boolean | false | Matches pathname only exactly, never as a prefix of a deeper route. |
| NavGroup.label | string | — | An optional heading rendered above the group's items. |
| NavGroup.items | NavItem[] | — | The items in this group, in display order. |
| NavBrand.name | string | — | The product or workspace name shown beside the mark. |
| NavBrand.initial | string | — | A one- or two-character mark shown when there is no logo image. |
| NavBrand.href | string | — | Where the brand links to, and the root of every breadcrumb trail. |
| NavBrand.caption | string | — | A small line under the name, e.g. a plan or environment label. |
| NavConfig.brand | NavBrand | — | The mark at the top of the shell and the first breadcrumb. |
| NavConfig.groups | NavGroup[] | — | The sidebar's sections, in display and flattenNav order. |
| NavConfig.footer | NavItem[] | — | 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
// 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 } : {}) }
}