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
"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
| Variant | How |
|---|---|
| Labelled group | SidebarNavGroup label="Workspace" — the label is a --muted-foreground, uppercase, --tracking-wide caption. |
| Unlabelled group | Omit label — the <ul> renders with no heading (a single-group nav rarely needs one). |
| Item with icon | SidebarNavItem icon={<Home />} — a --scale-4 leading glyph, aria-hidden, never the item's only label. |
| Current item | SidebarNavItem 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:
| State | Look |
|---|---|
| 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. |
| Disabled | Not 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.
| Token | Where used |
|---|---|
--sidebar | The nav's surface (applied by the container the caller puts the nav in) |
--sidebar-foreground | Resting item text (at 80%), group-label text inherits from it |
--sidebar-primary / --sidebar-primary-foreground | Current item background + text |
--sidebar-accent / --sidebar-accent-foreground | Hover background + text |
--sidebar-ring | Focus-visible ring on an item |
--muted-foreground | Group-label caption colour |
--radius-md | Item corner radius |
--tracking-wide | Group-label letter-spacing |
--scale-4 | Leading-icon size |
Do / Don’t
Do
- Give the
<nav>an accessible name —SidebarNavdefaultsaria-labelto "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
--cardor--secondaryfor the sidebar surface — use--sidebar-*, that is what it is for (Color). - Use
--sidebar-accentfor 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 —
SidebarNavrenders a<nav>with anaria-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-primaryfill is reinforcement;aria-currentis the actual signal. - Links — native
<a>elements:Tabmoves between items,Enteractivates, 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-hiddenspan; the visible text is always the accessible name. - Contrast — resting item text is
--sidebar-foregroundat 80% on--sidebar; the current item is the full--sidebar-primary-foregroundon--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.