ComponentsNavigation

Sidebar

The primary app navigation, dark in both themes. The active section's submenu stays open in place, other sections open as flyouts, and the rail turns every submenu into a flyout. Data-driven from one nav config that also feeds Breadcrumb, Page header and ⌘K — or composed by hand with identical DOM.

Radix Popover (flyouts), Radix Collapsible, Tooltip components/sidebar.tsx, lib/nav.ts, styles/navigation.csstheme · material · accent · density · font

Installation

$ pnpm add @vamos-ui/react

The whole kit as one package — get updates by bumping the version. Add the styles to globals.css: @import "@vamos-ui/react/styles.css";

Usage

import { Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarNav, SidebarCollapseButton, AppSwitcher, type NavNode } from "@vamos-ui/react";

const nav: NavNode[] = [
  { id: "dashboard", label: "Dashboard", href: "/site", icon: LayoutDashboard },
  { type: "group", id: "content", label: "Content", items: [
    { id: "posts", label: "Posts", href: "/site/posts", icon: FileText, children: [
      { id: "posts.all", label: "All posts", href: "/site/posts" },
      { id: "posts.categories", label: "Categories", href: "/site/posts/categories" },
    ] },
  ] },
];

// Link + currentPath come from VamosProvider (router-agnostic).
<Sidebar>
  <SidebarHeader><AppSwitcher apps={apps} activeApp="site" /></SidebarHeader>
  <SidebarContent><SidebarNav items={nav} badges={{ comments: 4 }} /></SidebarContent>
  <SidebarFooter><SidebarCollapseButton /></SidebarFooter>
</Sidebar>

Keyboard: Tab moves between items (this is site navigation, not role=menu). → (← in RTL) opens a flyout and focuses its first link; ↑↓ move inside; Esc or ← closes and returns focus.

Touch: the first tap on a section with a flyout opens it, the second follows the link.

Flyouts are portalled, so the scrolling menu can't clip them, and never blur (an almost opaque --nav-popover instead).

Examples

Composed by hand

SidebarGroup, SidebarItem and SidebarSubItem are the parts SidebarNav renders. Without a nav config an item is active when its href (or a child's) matches the current path; pass active to override.

Rail

Collapsed, every submenu opens as a flyout headed by the section name, leaves get a tooltip and badges become dots. In the App shell mod+\ toggles it (never mod+b — that is Bold in editors) and the choice is stored in the ui-sidebar cookie.

Three levels

Level 3 is a collapsible inline and a heading plus list inside a flyout. Items without href only open their children. A string disabled shows a reason badge.

Live badges

The badges map is separate from the config. Counts over 99 show 99+, the rail shows a dot, and the text joins the link's name: “Comments, 4 awaiting moderation”.

Role-based menu

filterNav runs before render, so hidden items aren't in the DOM. It is UI only: enforce every permission on the server.

Surface variant (client portal)

variant="surface" takes the content background — for client portals and websites. With one app the switcher is a static title.

API

Sidebar

PropTypeDefault
variant
Dark navigation in both themes, or the content surface.
"nav" | "surface""nav"
collapsed / defaultCollapsed / onCollapsedChange
Rail mode.
booleanfalse
collapsible
Allow the rail.
booleantrue
mode
mobile: always expanded, accordion levels (App shell's sheet).
"desktop" | "mobile""desktop"
shortcut
Toggle shortcut, e.g. "mod+\". App shell turns it on.
string | falsefalse
onNavigate
Called after a link is followed.
() => void—
labels
Override built-in strings (sidebar.*).
Partial<SidebarLabels>—

SidebarNav

PropTypeDefault
items
Items, groups ({ type: "group" }) and separators.
NavNode[]—
badges
Live counts by item id: { comments: 4, forms: { count: 12, tone: "danger" } }.
Record<string, BadgeSpec>—
features
Feature flags for items with feature.
Record<string, boolean>—
trail
Active trail override; default resolveActive(items, currentPath).
string[]—
aria-label
Wrap in its own <nav> (a second menu in the footer).
string—

NavItem

PropTypeDefault
id
Stable key for badges, the active trail and ⌘K.
string—
label / href / icon
No href: a parent that only opens its children.
string / string / LucideIcon—
children
Up to 3 levels.
NavItem[]—
match
Extra active patterns, "/posts/*" = any child path.
string[] | (path) => boolean—
permission
Hidden unless can() — UI only.
string | string[]—
feature
Hidden unless features[feature].
string—
external
New tab + ↗ (detected from absolute hrefs).
boolean—
keywords
Extra ⌘K search terms.
string[]—
badge
Static badge: number, string or { count, tone, dot, label }.
BadgeSpec—
disabled
A string is shown as the reason ("Coming soon").
boolean | string—

SidebarItem / SidebarSubItem

PropTypeDefault
id
Matches badges and the trail in SidebarNav.
string—
label / href / icon
icon on SidebarItem only.
string—
badge / disabled / external
As in NavItem.
BadgeSpec / boolean | string / boolean—
active
Force the active state.
boolean—
children
Submenu (inline when active, flyout otherwise).
SidebarSubItem[]—

SidebarGroup · SidebarHeader · SidebarContent · SidebarFooter · SidebarSeparator · SidebarCollapseButton · SidebarSkeleton

PropTypeDefault
label
SidebarGroup heading; a hairline in the rail.
ReactNode—
aria-label
SidebarContent is the <nav>.
string"Main navigation"
rows
SidebarSkeleton rows while permissions load.
number6

AppSwitcher

PropTypeDefault
apps
Every app the user can open. One app → static title.
{ id, name, description?, icon?, href }[]—
activeApp
Current app id.
string—
onSelect
Switch in place instead of following href.
(id) => void—
workspaces / activeWorkspace / onWorkspaceChange
Tenant switcher in the same panel.
…—
footer
Links under the list (“Manage apps & modules…”).
ReactNode—

Helpers (lib/nav)

PropTypeDefault
filterNav(nodes, { can, features })
Drops hidden items, empty groups and orphan separators.
NavNode[]—
resolveActive(nodes, path)
Longest-prefix match; / matches only exactly.
{ trail, leafId }—
flattenNav(nodes)
Navigable items with label trails, for ⌘K and Breadcrumb.
FlatNavItem[]—
parseSidebarCookie(value)
Read the ui-sidebar cookie on the server.
boolean | undefined—