Skip to content
Monogem

Component · Navigation & Feedback

Sidebar Nav

Overview

Grouped vertical navigation for an application shell's primary destinations, rendered on the dedicated --sidebar surface so it reads as structure, distinct from page content.

Use it for the persistent left-hand rail of an app. Reach for something else when the job is different:

  • Showing where the current page sits in a hierarchy → Breadcrumb.
  • Switching between views of the same page → Tabs.
  • A menu of actions/commands → Dropdown Menu.
  • Paging through a result set → Pagination.

Anatomy

<nav> landmark (SidebarNav) → one or more groups (SidebarNavGroup: an optional <p> section label over a <ul>) → items (SidebarNavItem: a <li> wrapping a native <a>, with an optional leading icon).

  • SidebarNav — the <nav> landmark.
  • SidebarNavGroup — an optional <p> section label over a <ul>.
  • SidebarNavItem — a <li> wrapping a native <a>, with an optional leading icon.

Compound parts, shadcn-style — the caller assembles the groups and marks which item is current. The component supplies the landmark, the list semantics, and the styling.

Live Example

Grouped

Unlabelled group

With leading icons

Responsive — persistent rail, drawer below `md`

Narrow viewport — the rail is hidden; use the menu button.

Code Example

sidebar-nav-showcase.tsx
"use client";

import type { MouseEvent } from "react";
import { BarChart3, FileText, Home, Menu, Settings, Users } from "lucide-react";

import { Button } from "@/components/ui/button";
import {
Sheet,
SheetBody,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
SidebarNav,
SidebarNavGroup,
SidebarNavItem,
} from "@/components/ui/sidebar-nav";

/**
* Live Sidebar Nav example — `"use client"` only so the demo can swallow the
* click; the parts themselves are server components. Items carry real `href`
* values (so hover, the focus ring, and the pointer cursor are genuine link
* behaviour) and `preventDefault()` stops the demo navigating to routes the
* docs site doesn't have. Shows a grouped nav with one `current` item, a
* single unlabelled group, a group with leading icons, and the responsive
* rail-plus-Sheet composition (Q11 — the component itself does no measurement).
* Which item is `current` is the caller's decision — the component does no
* route matching.
*/
const stayOnPage = (event: MouseEvent<HTMLAnchorElement>) =>
event.preventDefault();

function DemoNav() {
return (
<SidebarNav aria-label="Product">
<SidebarNavGroup label="Overview">
<SidebarNavItem href="/dashboard" current onClick={stayOnPage}>
Dashboard
</SidebarNavItem>
<SidebarNavItem href="/reports" onClick={stayOnPage}>
Reports
</SidebarNavItem>
</SidebarNavGroup>
<SidebarNavGroup label="Workspace">
<SidebarNavItem href="/projects" onClick={stayOnPage}>
Projects
</SidebarNavItem>
<SidebarNavItem href="/settings" onClick={stayOnPage}>
Settings
</SidebarNavItem>
</SidebarNavGroup>
</SidebarNav>
);
}

export function SidebarNavShowcase() {
return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Grouped
</h3>
<div className="w-64 rounded-md bg-sidebar p-3 text-sidebar-foreground">
<SidebarNav>
<SidebarNavGroup label="Overview">
<SidebarNavItem href="/dashboard" current onClick={stayOnPage}>
Dashboard
</SidebarNavItem>
<SidebarNavItem href="/reports" onClick={stayOnPage}>
Reports
</SidebarNavItem>
</SidebarNavGroup>
<SidebarNavGroup label="Workspace">
<SidebarNavItem href="/projects" onClick={stayOnPage}>
Projects
</SidebarNavItem>
<SidebarNavItem href="/members" onClick={stayOnPage}>
Members
</SidebarNavItem>
<SidebarNavItem href="/settings" onClick={stayOnPage}>
Settings
</SidebarNavItem>
</SidebarNavGroup>
</SidebarNav>
</div>
</section>

<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Unlabelled group
</h3>
<div className="w-64 rounded-md bg-sidebar p-3 text-sidebar-foreground">
<SidebarNav aria-label="Account">
<SidebarNavGroup>
<SidebarNavItem href="/account" current onClick={stayOnPage}>
Profile
</SidebarNavItem>
<SidebarNavItem href="/account/billing" onClick={stayOnPage}>
Billing
</SidebarNavItem>
<SidebarNavItem href="/account/security" onClick={stayOnPage}>
Security
</SidebarNavItem>
</SidebarNavGroup>
</SidebarNav>
</div>
</section>

<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
With leading icons
</h3>
<div className="w-64 rounded-md bg-sidebar p-3 text-sidebar-foreground">
<SidebarNav aria-label="Main">
<SidebarNavGroup>
<SidebarNavItem
href="/"
current
icon={<Home />}
onClick={stayOnPage}
>
Home
</SidebarNavItem>
<SidebarNavItem
href="/team"
icon={<Users />}
onClick={stayOnPage}
>
Team
</SidebarNavItem>
<SidebarNavItem
href="/analytics"
icon={<BarChart3 />}
onClick={stayOnPage}
>
Analytics
</SidebarNavItem>
<SidebarNavItem
href="/docs"
icon={<FileText />}
onClick={stayOnPage}
>
Docs
</SidebarNavItem>
<SidebarNavItem
href="/settings"
icon={<Settings />}
onClick={stayOnPage}
>
Settings
</SidebarNavItem>
</SidebarNavGroup>
</SidebarNav>
</div>
</section>

<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Responsive — persistent rail, drawer below `md`
</h3>
{/* Wide: the rail is always visible. Narrow (below md): the rail is
hidden and the same nav opens in a Sheet. Version 1 Sheet supports
side="right" / "bottom" only, and requires a SheetTitle. */}
<div className="flex items-start gap-4">
<aside className="hidden w-64 rounded-md bg-sidebar p-3 text-sidebar-foreground md:block">
<DemoNav />
</aside>
<div className="flex items-center gap-3 md:hidden">
<Sheet>
<SheetTrigger>
<Button
variant="outline"
size="icon"
aria-label="Open navigation"
>
<Menu />
</Button>
</SheetTrigger>
<SheetContent side="right" className="bg-sidebar">
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
<SheetBody className="text-sidebar-foreground">
<DemoNav />
</SheetBody>
</SheetContent>
</Sheet>
<p className="text-xs leading-snug text-muted-foreground">
Narrow viewport — the rail is hidden; use the menu button.
</p>
</div>
</div>
</section>
</div>
);
}

Variants

VariantHow
Labelled groupSidebarNavGroup label="Workspace" — the label is a --muted-foreground, uppercase, --tracking-wide caption.
Unlabelled groupOmit label — the <ul> renders with no heading (a single-group nav rarely needs one).
Item with iconSidebarNavItem icon={<Home />} — a --scale-4 leading glyph, aria-hidden, never the item's only label.
Current itemSidebarNavItem current — see States and Accessibility.

Not in V1: collapsible / disclosure groups (that is Accordion's job), an icon-only "mini rail" collapse mode, nested / tree navigation deeper than one group level, and per-item badges or counts.

States

Per item, using the sidebar tokens exactly as Color maps them:

StateLook
Resting--sidebar-foreground at 80% opacity, transparent background, --radius-md corners.
Hover--sidebar-accent background + --sidebar-accent-foreground text — the token pair reserved for nav hover, one step out from the sidebar surface so it stays visible against it.
Focus-visible--scale-0-5 ring in --sidebar-ring (the sidebar's own focus colour, not the global --ring), :focus-visible only.
Current--sidebar-primary background + --sidebar-primary-foreground text + font-medium, plus aria-current="page". The selected surface is deliberately the --sidebar-primary pair, not --sidebar-accent — hover and "you are here" must not look alike.
DisabledNot part of the baseline — a destination a user can't reach is normally omitted from the nav, not shown greyed out.

Usage Guidance

Tokens

Sidebar Nav reads the sidebar semantic-token family documented in Color — Sidebar composition and does not introduce a competing navigation palette. It is router-agnostic.

TokenWhere used
--sidebarThe nav's surface (applied by the container the caller puts the nav in)
--sidebar-foregroundResting item text (at 80%), group-label text inherits from it
--sidebar-primary / --sidebar-primary-foregroundCurrent item background + text
--sidebar-accent / --sidebar-accent-foregroundHover background + text
--sidebar-ringFocus-visible ring on an item
--muted-foregroundGroup-label caption colour
--radius-mdItem corner radius
--tracking-wideGroup-label letter-spacing
--scale-4Leading-icon size

Do / Don’t

Do

  • Give the <nav> an accessible name — SidebarNav defaults aria-label to "Sidebar"; override it ("Main", "Account") when a page has more than one nav landmark.
  • Mark exactly one item current, matching the page the user is on. The caller owns that decision — the component does no route matching.
  • Group related destinations and label the groups when there is more than one.
  • Keep labels short — one line, no wrapping.

Don’t

  • Repoint --card or --secondary for the sidebar surface — use --sidebar-*, that is what it is for (Color).
  • Use --sidebar-accent for the current item — it is the hover token; the current item is --sidebar-primary.
  • Mark more than one item current.
  • Build a collapsible group or an icon-only rail by reaching around the component — those are deferred.
  • Rely on an icon alone to label an item — the text is the label; the icon is decorative.

Accessibility

  • Landmark — SidebarNav renders a <nav> with an aria-label (default "Sidebar"), applied after {...props} so it can't be dropped by accident. Name it uniquely when the page has more than one nav.
  • List semantics — groups are real <ul> / <li>, so assistive tech announces the item count per group.
  • Current location — the current item is aria-current="page" (WAI-ARIA APG). Exactly one per nav. The --sidebar-primary fill is reinforcement; aria-current is the actual signal.
  • Links — native <a> elements: Tab moves between items, Enter activates, and the focus ring (--sidebar-ring) is keyboard-only. No roving tabindex — every item is a normal tab stop, the same model as Breadcrumb and Pagination.
  • Icons — wrapped in an aria-hidden span; the visible text is always the accessible name.
  • Contrast — resting item text is --sidebar-foreground at 80% on --sidebar; the current item is the full --sidebar-primary-foreground on --sidebar-primary. Both pairs meet WCAG AA in light and dark, per Color — Sidebar composition.

For client-side routing, render your router's own link element as a bare <li> inside SidebarNavGroup, carrying the same classes SidebarNavItem applies (including aria-current="page" on the active one).

Responsive Behavior

The component renders a static vertical list at every viewport — it does no measurement and has no built-in breakpoint.

Showing it as a persistent rail on wide screens and a slide-in drawer on narrow ones is a caller composition with Sheet — the same approach this documentation site's own shell uses (a persistent rail plus a mobile drawer). The Live Example above demonstrates it; the shape is:

// Wide: a persistent rail.
<aside className="hidden w-64 bg-sidebar md:block">
<SidebarNav>{/* groups + items */}</SidebarNav>
</aside>

// Narrow: the same nav inside a Sheet. Version 1 Sheet supports side="right"
// and side="bottom" only, and requires a SheetTitle (it names the dialog).
// SheetTrigger clones its single child and takes no className of its own —
// put the responsive-visibility class on a wrapper or the button.
<div className="md:hidden">
<Sheet>
<SheetTrigger>
<Button variant="outline" size="icon" aria-label="Open navigation">
<Menu />
</Button>
</SheetTrigger>
<SheetContent side="right" className="bg-sidebar">
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
<SheetBody>
<SidebarNav>{/* the same groups + items */}</SidebarNav>
</SheetBody>
</SheetContent>
</Sheet>
</div>

There is no icon-only "mini rail" collapse mode in V1 — a stateful width animation is beyond Monogem's documented navigation needs.

Related Components / Patterns

  • Navbar — global, top-of-page navigation.
  • Bottom Nav — the mobile-only counterpart.
  • Breadcrumb — orientation within a hierarchy.
  • Navigation — how the navigation primitives combine on one screen.