Sidebar
The tool's main navigation down the left side: the team selector at its head, each place a `NavItem` row, the current one white and lifted off the warm ground.
ui.sidebar
components/ui/nav-item.tsxUse case
Every internal tool with more than a few sections gets one: Orders, Clients, Invoices, Schedule, and Settings at the foot. It runs the window's full height and is sticky, its list fills what is left under the head and fades at both ends, and it never scrolls itself to follow the page. Each `NavItem` renders as a Next `Link`, with `current` on the page being shown and a count of what waits (unread, unpaid) as a small badge at its end.
Use it when
- The tool has four or more top-level sections
- People move between sections all day and need to see where they are
- The tool serves several companies or workspaces (the team selector at its head)
Use something else when
- Sections of one record or one page: Tabs
- The way back up from a nested record: Breadcrumb
- The places on a narrow window, opened from a button: Sheet
- Only switching between companies or workspaces: Team selector
Examples
The page
Code
Import
import { useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { InboxIcon, SettingsIcon, UsersIcon } from "@/components/icons"
import { Badge } from "@/components/ui/badge"
import { NavItem } from "@/components/ui/nav-item"
import { TeamSelector, type Team } from "@/components/ui/team-selector"Usage
const NAV = [
{ href: "/orders", label: "Orders", icon: InboxIcon, count: 4 },
{ href: "/clients", label: "Clients", icon: UsersIcon, count: 0 },
]
export function AppSidebar({ teams }: { teams: Team[] }) {
const pathname = usePathname()
const [team, setTeam] = useState(teams[0].id)
return (
<aside className="sticky top-0 flex h-svh w-60 shrink-0 flex-col gap-4 bg-surface p-3">
<TeamSelector teams={teams} value={team} onValueChange={setTeam} className="max-w-full" />
<nav aria-label="Main" className="scroll-fade grid min-h-0 flex-1 content-start gap-0.5 overflow-y-auto">
{NAV.map((n) => (
<NavItem key={n.href} current={pathname === n.href} render={<Link href={n.href} />}>
<n.icon />
<span className="flex-1">{n.label}</span>
{n.count > 0 && <Badge size="sm">{n.count}</Badge>}
</NavItem>
))}
</nav>
<NavItem current={pathname === "/settings"} render={<Link href="/settings" />}>
<SettingsIcon />
Settings
</NavItem>
</aside>
)
}Rules
- It runs the window's full height and is sticky (`sticky top-0 h-svh`); its list takes the rest with `min-h-0 flex-1 overflow-y-auto scroll-fade`, so rows fade at both ends instead of being cut.
- It never scrolls itself: the current row is highlighted, but nothing calls `scrollIntoView` to move the list as the page changes.
- One row has `current` (it sets `aria-current="page"`); rows are links through `render={<Link href="/clients" />}`.
- An icon leads each row and the row sizes it; a count is a `<Badge size="sm">` at the row's end.
- Settings sit at the foot, under the list.