Loading...
Loading...
Build a modern, collapsible sidebar for SaaS dashboards following the ChatGPT/Notion design pattern
npx skill4agent add blink-new/claude saas-sidebarnpx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheetcomponents/ui/sidebar.tsx<aside>Sidebar┌──────────────────────────────────────────────┐
│ SidebarProvider (flex container, min-h-svh) │
│ │
│ ┌─ Sidebar outer div ──────────────────┐ │
│ │ [Spacer div] ← reserves width │ │
│ │ relative w-[--sidebar-width] │ │
│ │ (pushes SidebarInset right) │ │
│ │ │ │
│ │ [Fixed div] ← actual sidebar │ │
│ │ fixed inset-y-0 z-10 │ │
│ │ w-[--sidebar-width] │ │
│ │ (contains children) │ │
│ └───────────────────────────────────────┘ │
│ │
│ ┌─ SidebarInset (main) ────────────────┐ │
│ │ flex-1 overflow-y-auto h-dvh │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────┘transition-[width] duration-200 ease-linearSidebarProvider| State | Variable | Value |
|---|---|---|
| Expanded | | |
| Collapsed | | |
| Mobile | | |
type SidebarContextProps = {
state: "expanded" | "collapsed" // derived from open
open: boolean // true = expanded
setOpen: (open: boolean) => void
openMobile: boolean // separate mobile Sheet state
setOpenMobile: (open: boolean) => void
isMobile: boolean // < 768px
toggleSidebar: () => void // smart: routes to mobile or desktop
}useSidebar()collapsedSidebar<div data-state="collapsed" data-collapsible="icon" data-variant="sidebar" data-side="left">/* Force menu buttons to 32×32px centered squares when collapsed */
group-data-[collapsible=icon]:!size-8
group-data-[collapsible=icon]:!p-2
/* Hide text labels smoothly (negative margin pulls up, opacity fades) */
group-data-[collapsible=icon]:-mt-8
group-data-[collapsible=icon]:opacity-0
/* Hard-hide sub-menus, group actions, badges when collapsed */
group-data-[collapsible=icon]:hidden
/* Prevent horizontal scrollbar in 48px-wide collapsed column */
group-data-[collapsible=icon]:overflow-hiddengroup peerSidebarInset// SidebarInset reacts to sidebar state for inset variant
"md:peer-data-[variant=inset]:m-2"
"md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2"SidebarMenuButtonconst sidebarMenuButtonVariants = cva(
// Base: flex row, gap-2, overflow-hidden, rounded-md, p-2
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm " +
// Auto-truncate the last span (label text)
"[&>span:last-child]:truncate " +
// Icons: always 16×16, never shrink
"[&>svg]:size-4 [&>svg]:shrink-0 " +
// COLLAPSED: force to 32×32 square with centered icon
"group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 " +
// Transitions on width, height, padding (not all)
"transition-[width,height,padding] " +
// Active state
"data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium",
{
variants: {
size: {
default: "h-8 text-sm", // 32px — nav items
sm: "h-7 text-xs", // 28px — compact
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", // 48px — header (workspace switcher)
},
},
}
)3remp-2!size-8!p-2overflow-hidden[&>svg]:size-4 [&>svg]:shrink-0"lg"h-12group-data-[collapsible=icon]:!p-0SidebarMenuButtontooltip<SidebarMenuButton asChild isActive={isActive} tooltip="Home">
<Link href="/"><Home className="h-4 w-4" /><span>Home</span></Link>
</SidebarMenuButton><Tooltip><TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile} // only show when collapsed + desktop
/>TooltipProvider delayDuration={0}SidebarProviderasChildasChild// SidebarMenuButton renders as <Link> not <button><Link>
<SidebarMenuButton asChild tooltip="Home">
<Link href="/">...</Link>
</SidebarMenuButton>Collapsed (idle): [OrgAvatar] ← icon only, 7×7
Collapsed (hover): [ExpandBtn] ← replaces avatar on sidebar hover
Expanded: [OrgSwitcher ——— CollapseBtn] ← full row<Sidebar collapsible="icon" className="border-r group/sidebar">
<SidebarHeader className="pb-0">
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-1">
<ExpandButton /> {/* hidden → shows on sidebar hover */}
<OrgSwitcher /> {/* avatar hides on sidebar hover when collapsed */}
<CollapseToggle /> {/* early-returns null when collapsed */}
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>function ExpandButton() {
const { toggleSidebar, state } = useSidebar()
if (state !== 'collapsed') return null
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => { e.stopPropagation(); toggleSidebar() }}
className="hidden group-hover/sidebar:flex items-center justify-center h-7 w-7 rounded-md bg-accent text-foreground cursor-pointer hover:bg-accent/80 transition-colors shrink-0"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right" align="center">Expand sidebar</TooltipContent>
</Tooltip>
)
}hidden group-hover/sidebar:flexh-7 w-7e.stopPropagation()function CollapseToggle() {
const { toggleSidebar, state } = useSidebar()
if (state !== 'expanded') return null
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={toggleSidebar}
className="h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground/50 hover:text-muted-foreground hover:bg-accent transition-colors cursor-pointer shrink-0"
>
<PanelLeftOpen className="h-4 w-4 rotate-180" />
</button>
</TooltipTrigger>
<TooltipContent side="right">Close sidebar</TooltipContent>
</Tooltip>
)
}PanelLeftOpenrotate-180PanelLeftClose<SidebarMenuButton size="lg" className={cn("w-full cursor-pointer", collapsed && "justify-center")}>
<div className={cn(
"flex items-center justify-center h-7 w-7 rounded-md bg-primary text-primary-foreground text-xs font-bold shrink-0",
collapsed && "group-hover/sidebar:hidden" // ← KEY: hides when sidebar hovered
)}>
{initial}
</div>
{!collapsed && (
<>
<div className="flex-1 min-w-0 text-left">
<p className="text-sm font-semibold truncate leading-tight">{name}</p>
<p className="text-[10px] text-muted-foreground leading-tight">{subtitle}</p>
</div>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</>
)}
</SidebarMenuButton>leading-tighth-12function NavItem({ href, label, icon: Icon, badge, onClick }: {
href?: string; label: string; icon: ComponentType<{ className?: string }>
badge?: string; onClick?: () => void
}) {
const pathname = usePathname()
const isActive = href ? pathname === href : false
const content = (
<>
<Icon className="h-4 w-4" />
<span>{label}</span>
{badge && (
<span className="ml-auto flex items-center gap-0.5 text-[10px] text-muted-foreground/50">
<kbd className="inline-flex h-5 items-center rounded border border-border/50 bg-muted/50 px-1 font-mono text-[10px]">⌘</kbd>
<kbd className="inline-flex h-5 items-center rounded border border-border/50 bg-muted/50 px-1 font-mono text-[10px]">{badge}</kbd>
</span>
)}
</>
)
if (onClick) {
return (
<SidebarMenuItem>
<SidebarMenuButton isActive={isActive} tooltip={label} onClick={onClick} className="cursor-pointer">
{content}
</SidebarMenuButton>
</SidebarMenuItem>
)
}
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive} tooltip={label}>
<Link href={href!}>{content}</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}function CollapsibleSection({ label, icon: Icon, items }: { ... }) {
const [open, setOpen] = useState(true)
return (
<Collapsible open={open} onOpenChange={setOpen}>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton className="cursor-pointer" tooltip={label}>
<Icon className="h-4 w-4" />
<span>{label}</span>
<ChevronRight className={cn(
"ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground/50 transition-transform duration-200",
open && "rotate-90"
)} />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{items.map((item) => (
<SidebarMenuSubItem key={item.id}>
<SidebarMenuSubButton asChild>
<Link href={item.href}><span className="truncate">{item.name}</span></Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
)
}SidebarMenuSubgroup-data-[collapsible=icon]:hidden<SidebarGroup className="py-1">
<SidebarGroupLabel className="text-[10px] uppercase tracking-wider text-muted-foreground/60 font-medium">
Projects
</SidebarGroupLabel>
<SidebarMenu>{/* items */}</SidebarMenu>
</SidebarGroup>-mt-8 opacity-0display:nonetransition-[margin,opacity] duration-200 ease-linear<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Projects">
<Link href="/projects"><FolderOpen className="h-4 w-4" /><span>Projects</span></Link>
</SidebarMenuButton>
<SidebarMenuAction showOnHover>
<Plus className="h-4 w-4" />
</SidebarMenuAction>
</SidebarMenuItem>absolute right-1md:opacity-0 group-hover/menu-item:opacity-100function UsageWidget() {
const { state } = useSidebar()
const collapsed = state === 'collapsed'
// Collapsed: centered icon with tooltip
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button className="flex items-center justify-center mx-auto w-8 h-8 cursor-pointer hover:bg-accent/50 rounded-md transition-colors">
<Gauge className="h-4 w-4 text-muted-foreground" />
</button>
</TooltipTrigger>
<TooltipContent side="right">75% credits used</TooltipContent>
</Tooltip>
)
}
// Expanded: full widget
return (
<div className="mx-2 px-3 py-2 rounded-md hover:bg-accent/50 transition-colors cursor-pointer space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11px] text-muted-foreground">250 credits left</span>
<span className="text-[10px] text-muted-foreground/60">75%</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-primary transition-all duration-300" style={{ width: '75%' }} />
</div>
</div>
)
}function UserRow() {
const { state } = useSidebar()
const collapsed = state === 'collapsed'
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={userName} className={cn(collapsed && "flex items-center justify-center")}>
<Link href="/settings" className="flex items-center gap-2 cursor-pointer">
<Avatar className="h-6 w-6 shrink-0">
<AvatarImage src={photo} />
<AvatarFallback className="text-[10px] bg-muted">{initial}</AvatarFallback>
</Avatar>
{!collapsed && (
<>
<span className="truncate text-sm font-medium">{userName}</span>
<Settings className="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground/50 hover:text-muted-foreground transition-colors" />
</>
)}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
)
}SidebarMenuButton tooltip=h-6 w-6!size-8function OrgSwitcher() {
const { state } = useSidebar()
const [open, setOpen] = useState(false)
const collapsed = state === 'collapsed'
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<SidebarMenuButton size="lg" className={cn("w-full cursor-pointer", collapsed && "justify-center")}>
<div className={cn(
"flex items-center justify-center h-7 w-7 rounded-md bg-primary text-primary-foreground text-xs font-bold shrink-0",
collapsed && "group-hover/sidebar:hidden"
)}>
{initial}
</div>
{!collapsed && (
<>
<div className="flex-1 min-w-0 text-left">
<p className="text-sm font-semibold truncate leading-tight">{orgName}</p>
<p className="text-[10px] text-muted-foreground leading-tight">{planLabel}</p>
</div>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</>
)}
</SidebarMenuButton>
</PopoverTrigger>
<PopoverContent
align="start"
side={collapsed ? 'right' : 'bottom'}
sideOffset={4}
className="w-60 p-1"
>
{/* Org list items */}
{orgs.map((org) => (
<button
key={org.id}
onClick={() => switchOrg(org.id)}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent transition-colors w-full cursor-pointer text-sm"
>
<div className="flex items-center justify-center h-6 w-6 rounded bg-primary/10 text-primary text-[10px] font-bold shrink-0">
{org.name.charAt(0)}
</div>
<span className="flex-1 truncate font-medium">{org.name}</span>
{org.id === active.id && <Check className="h-3.5 w-3.5 shrink-0 text-primary" />}
</button>
))}
<SidebarSeparator className="my-1" />
<Link href="/settings" className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors cursor-pointer">
<Settings className="h-3.5 w-3.5" /> Settings
</Link>
<button className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors cursor-pointer w-full">
<Plus className="h-3.5 w-3.5" /> New workspace
</button>
</PopoverContent>
</Popover>
)
}side"right"<SidebarRail />w-4-right-42pxhover:after:bg-sidebar-borderSidebaruseIsMobile()hidden md:blockSheettoggleSidebar()const toggleSidebar = () => isMobile ? setOpenMobile(o => !o) : setOpen(o => !o)<SidebarTrigger className="md:hidden" /> // PanelLeft icon, h-7 w-7useIsMobileconst MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}SidebarProvidertoggleSidebar()const SIDEBAR_KEY = 'sidebar_state'
const [open, setOpen] = useState(() => {
if (typeof window === 'undefined') return true
const stored = localStorage.getItem(SIDEBAR_KEY)
return stored === null ? true : stored === 'true'
})
const handleOpenChange = (value: boolean) => {
setOpen(value)
localStorage.setItem(SIDEBAR_KEY, String(value))
}
<SidebarProvider open={open} onOpenChange={handleOpenChange}>document.cookie = `sidebar_state=${openState}; path=/; max-age=${60 * 60 * 24 * 7}`function SidebarSkeleton() {
return (
<div className="flex min-h-screen">
<div className="w-64 shrink-0 border-r bg-sidebar p-3 space-y-4">
<div className="flex items-center gap-2">
<div className="h-7 w-7 rounded-md bg-muted animate-pulse" />
<div className="flex-1 space-y-1.5">
<div className="h-3 w-28 rounded bg-muted animate-pulse" />
<div className="h-2 w-16 rounded bg-muted animate-pulse" />
</div>
</div>
<div className="space-y-1 pt-2">
{[...Array(5)].map((_, i) => (
<div key={i} className="h-8 rounded-md bg-muted/50 animate-pulse" />
))}
</div>
</div>
<div className="flex-1 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
</div>
</div>
)
}'use client'
import { useState } from 'react'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import { AppSidebar } from './app-sidebar'
const SIDEBAR_KEY = 'sidebar_state'
export function DashboardLayout({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(() => {
if (typeof window === 'undefined') return true
const stored = localStorage.getItem(SIDEBAR_KEY)
return stored === null ? true : stored === 'true'
})
const handleOpenChange = (value: boolean) => {
setOpen(value)
localStorage.setItem(SIDEBAR_KEY, String(value))
}
return (
<SidebarProvider open={open} onOpenChange={handleOpenChange}>
<AppSidebar />
<SidebarInset className="overflow-y-auto h-dvh">
{children}
</SidebarInset>
</SidebarProvider>
)
}h-dvhh-screenexport function AppSidebar() {
return (
<Sidebar collapsible="icon" className="border-r group/sidebar">
<SidebarHeader className="pb-0">
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-1">
<ExpandButton />
<OrgSwitcher />
<CollapseToggle />
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup className="py-1">
<SidebarMenu>
<NavItem href="/dashboard" label="Home" icon={Home} />
<NavItem label="Search" icon={Search} badge="K" onClick={openSearch} />
</SidebarMenu>
</SidebarGroup>
<SidebarGroup className="py-1">
<SidebarGroupLabel>Projects</SidebarGroupLabel>
<SidebarMenu>
<CollapsibleSection label="Recent" icon={Clock} items={recentItems} />
<NavItem href="/projects" label="All projects" icon={FolderOpen} />
<NavItem href="/starred" label="Starred" icon={Star} />
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="gap-0.5 pb-2">
<SidebarSeparator />
<UsageWidget />
<UserRow />
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}:root {
--sidebar: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-border: 220 13% 91%;
--sidebar-accent: 220 14.3% 95.9%;
--sidebar-accent-foreground: 220.9 39.3% 11%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--sidebar: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}theme.extend.colorssidebar: {
DEFAULT: "hsl(var(--sidebar))",
foreground: "hsl(var(--sidebar-foreground))",
border: "hsl(var(--sidebar-border))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
ring: "hsl(var(--sidebar-ring))",
},collapsible="icon"<Sidebar>group/sidebar<Sidebar>useSidebar()collapsedSidebarMenuButton tooltip={label}group-data-[collapsible=icon]:h-7 w-7e.stopPropagation()PanelLeftOpenrotate-180leading-tightshrink-0truncatemin-w-0cursor-pointerTooltipPopoverTriggerDropdownMenuTriggertransition-alltransition-[width]<aside>w-163remdisplay:none-mt-8 opacity-0h-screenh-dvhTooltipProviderSidebarProviderTooltipPopoverDialog<Tooltip><PopoverContent>PopoverContentDialogContentSheetContentshowTooltipsinterface ThemeToggleProps {
showTooltips?: boolean // default true
}
function ThemeToggle({ showTooltips = true }: ThemeToggleProps) {
const btn = <button aria-label={label}>...</button>
// Skip tooltip wrapper when inside popover/dialog
if (!showTooltips) return btn
return (
<Tooltip>
<TooltipTrigger asChild>{btn}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
// Usage inside popover — tooltips disabled (label "Theme" provides context)
<PopoverContent>
<span>Theme</span>
<ThemeToggle showTooltips={false} />
</PopoverContent>
// Usage in header — tooltips enabled (icon-only, needs tooltip)
<ThemeToggle showTooltips={true} />delayDurationdelayDurationnpx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheetSidebarProvideropenonOpenChangeSidebar collapsible="icon" className="border-r group/sidebar"ExpandButtonhidden group-hover/sidebar:flexCollapseTogglePanelLeftOpen rotate-180group-hover/sidebar:hiddenSidebarMenuButton tooltip={label}SidebarGroupLabelCollapsibleSidebarMenuSubSidebarRailSidebarInset className="overflow-y-auto h-dvh"w-64