saas-sidebar
SaaS Collapsible Sidebar
Build a polished, collapsible sidebar using the shadcn/ui Sidebar component system. Covers every detail: icon-mode centering, hover-swap expand button, auto-tooltips, keyboard shortcuts, mobile Sheet, state persistence, loading skeletons.
When to Use
- SaaS dashboard with sidebar navigation
- Collapsible/minimizable sidebar (icon-only mode)
- Responsive layout with mobile sheet overlay
Quick Start
npx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheet
This generates components/ui/sidebar.tsx (~770 lines) with ALL sidebar primitives. Do NOT build a custom <aside>.
Architecture
How the Layout Works (Dual-Div Trick)
The Sidebar component renders two divs on desktop:
┌──────────────────────────────────────────────┐
│ 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 │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
Both divs transition width together: transition-[width] duration-200 ease-linear. The spacer ensures the main content never overlaps the sidebar.
Width Constants (CSS Variables)
Set by SidebarProvider as inline CSS custom properties:
| State | Variable | Value |
|---|---|---|
| Expanded | --sidebar-width |
16rem (256px) |
| Collapsed | --sidebar-width-icon |
3rem (48px) |
| Mobile | --sidebar-width |
18rem (288px) |
State Context
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
}
Access anywhere via useSidebar(). Never pass collapsed as prop.
Data Attribute Styling (No Prop Drilling)
The outer Sidebar div sets data attributes that children react to via Tailwind group selectors:
<div data-state="collapsed" data-collapsible="icon" data-variant="sidebar" data-side="left">
Key selectors and what they do:
/* 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-hidden
Peer Coordination (Sidebar ↔ Main Content)
The sidebar outer div has group peer. SidebarInset uses peer selectors:
// 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"
The Centering Magic (How Icons Align Perfectly)
This is the most important detail. SidebarMenuButton uses CVA variants:
const 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)
},
},
}
)
Why everything centers when collapsed:
- Container is
3rem(48px) wide withp-2(8px each side) = 32px usable - Button forced to
!size-8(32px) with!p-2(8px padding) = icon at center overflow-hiddenclips any text that hasn't faded yet- Icons have
[&>svg]:size-4 [&>svg]:shrink-0= always 16×16, never compressed
Size "lg" for header:
h-12(48px) gives room for two-line text (name + subtitle)group-data-[collapsible=icon]:!p-0removes padding so the h-7 w-7 avatar fits cleanly
Built-in Tooltip System
SidebarMenuButton has a tooltip prop. NO manual wrapping needed:
<SidebarMenuButton asChild isActive={isActive} tooltip="Home">
<Link href="/"><Home className="h-4 w-4" /><span>Home</span></Link>
</SidebarMenuButton>
Internally, it wraps the button in <Tooltip> with auto-visibility:
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile} // only show when collapsed + desktop
/>
TooltipProvider delayDuration={0} is set at the SidebarProvider level = instant tooltips.
The asChild / Slot Pattern
Every component supports asChild (Radix Slot). When true, it merges its props into the child element instead of rendering a wrapper. This is why this works:
// SidebarMenuButton renders as <Link> not <button><Link>
<SidebarMenuButton asChild tooltip="Home">
<Link href="/">...</Link>
</SidebarMenuButton>
The Expand/Collapse Pattern
How It Works
When collapsed, hovering anywhere on the sidebar swaps the header avatar for an expand button:
Collapsed (idle): [OrgAvatar] ← icon only, 7×7
Collapsed (hover): [ExpandBtn] ← replaces avatar on sidebar hover
Expanded: [OrgSwitcher ——— CollapseBtn] ← full row
Implementation
<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>
ExpandButton
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>
)
}
Key classes:
hidden group-hover/sidebar:flex— invisible by default, appears when sidebar hoveredh-7 w-7— matches the org avatar exactly (zero layout shift)e.stopPropagation()— prevents the click from reaching the PopoverTrigger behind it
CollapseToggle
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>
)
}
Key: same PanelLeftOpen icon with rotate-180 — not a separate PanelLeftClose icon.
Org/Team Avatar (Hides on Hover When Collapsed)
<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>
Note: leading-tight on both lines keeps them compact within the h-12 (size="lg") button.
Navigation Items
Standard Nav Item
function 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>
)
}
When collapsed: icon centers at 32×32, span truncates to invisible, badge hides (overflow-hidden clips it), tooltip appears on hover.
Collapsible Nested Section
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>
)
}
SidebarMenuSub auto-hides when collapsed: group-data-[collapsible=icon]:hidden. The parent button still shows as an icon-only tooltip item.
Group Labels (Auto-Hide Trick)
<SidebarGroup className="py-1">
<SidebarGroupLabel className="text-[10px] uppercase tracking-wider text-muted-foreground/60 font-medium">
Projects
</SidebarGroupLabel>
<SidebarMenu>{/* items */}</SidebarMenu>
</SidebarGroup>
Built-in auto-hide uses -mt-8 opacity-0 (NOT display:none). This keeps the label in DOM so items below shift up with a smooth transition-[margin,opacity] duration-200 ease-linear instead of a hard jump.
Inline Action Button (Show on Hover)
<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>
The action is positioned absolute right-1 and uses md:opacity-0 group-hover/menu-item:opacity-100 to appear only on hover. Auto-hidden when collapsed.
Footer Widgets (Collapsed ↔ Expanded Pattern)
Footer items must gracefully transform between full content (expanded) and centered icon + tooltip (collapsed).
Pattern: Early Return for Collapsed
function 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>
)
}
User Row (Using SidebarMenuButton)
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>
)
}
Uses SidebarMenuButton tooltip= so collapsed state gets auto-tooltip. Avatar at h-6 w-6 fits within the !size-8 collapsed button.
Org/Team Switcher (Popover in Header)
Critical: DO NOT wrap PopoverTrigger in Tooltip — breaks click handling.
function 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>
)
}
Popover side flips to "right" when collapsed so it doesn't overlap the narrow sidebar.
SidebarRail (Edge Hover Toggle)
<SidebarRail />
An invisible w-4 hit area positioned at -right-4 of the sidebar. On hover, it shows a 2px vertical line (hover:after:bg-sidebar-border). Clicking toggles the sidebar. Users discover this naturally — it's a secondary toggle alongside the header buttons.
Mobile Behavior
Automatic. The Sidebar component checks useIsMobile() (768px breakpoint) and renders:
- Desktop:
hidden md:blockwith collapse animation - Mobile: Radix
Sheetoverlay (slide-in from left, with backdrop)
toggleSidebar() routes to the correct behavior:
const toggleSidebar = () => isMobile ? setOpenMobile(o => !o) : setOpen(o => !o)
Mobile trigger in your page header:
<SidebarTrigger className="md:hidden" /> // PanelLeft icon, h-7 w-7
The useIsMobile hook:
const 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
}
Keyboard Shortcut
Built into SidebarProvider: ⌘B (Mac) / Ctrl+B (Windows). No configuration needed. Calls toggleSidebar().
State Persistence
localStorage (instant on mount)
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}>
Cookie (SSR, set by SidebarProvider internally)
document.cookie = `sidebar_state=${openState}; path=/; max-age=${60 * 60 * 24 * 7}`
Loading Skeleton (Zero Layout Shift)
Match sidebar width and element sizes:
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>
)
}
Full Assembly
Layout (wraps your app)
'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>
)
}
Note: h-dvh (dynamic viewport height) is better than h-screen on mobile Safari.
Sidebar (all sections)
export 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>
)
}
CSS Variables (globals.css)
: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%;
}
Tailwind config (theme.extend.colors):
sidebar: {
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))",
},
Critical Rules
DO
collapsible="icon"on<Sidebar>for icon-only collapsegroup/sidebarclass on<Sidebar>for hover detectionuseSidebar()to read state — never prop-drillcollapsedSidebarMenuButton tooltip={label}for auto-tooltipsgroup-data-[collapsible=icon]:selectors for collapsed styling- Match expand button and avatar sizes exactly (
h-7 w-7) e.stopPropagation()on expand button (prevents popover trigger)PanelLeftOpenwithrotate-180for collapse (one icon, not two)leading-tightfor multi-line text in header buttonshrink-0on all icons and trailing elementstruncateon all text that could overflowmin-w-0on flex children that contain truncated textcursor-pointeron all clickable elements
DO NOT
- Nest
TooltipinsidePopoverTriggerorDropdownMenuTrigger - Use
transition-all— use specific properties (transition-[width]) - Build a custom
<aside>— use the shadcn/ui Sidebar system - Use
w-16(64px) for collapsed — it's3rem(48px) via CSS var - Use
display:nonefor group labels — use the-mt-8 opacity-0trick - Use
h-screen— useh-dvhfor mobile Safari compatibility - Add
TooltipProvideryourself — it's already inSidebarProvider - Put
Tooltip-wrapped elements inside aPopover/Dialogcontent — Radix tooltips trigger on focus, not just hover. When a popover opens, focus moves into its content and auto-fires the tooltip on the first focusable element. See "Tooltip-on-Focus Gotcha" below.
Tooltip-on-Focus Gotcha (Radix)
Problem: Radix <Tooltip> triggers on both hover AND focus. When you place tooltip-wrapped buttons inside a <PopoverContent>, opening the popover moves focus into the content, which immediately triggers the tooltip on the first focusable element — even without hovering.
This affects any component with tooltips rendered inside:
PopoverContentDialogContentSheetContent- Any container that receives focus on open
Solution: Add a showTooltips prop to components that contain tooltips, and disable them when used inside focus-trapping containers:
interface 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} />
Why not just increase delayDuration? The delay only affects hover. Focus-triggered tooltips ignore delayDuration in Radix and fire immediately regardless of the delay value.
Rule of thumb: If a tooltip-wrapped element appears inside a focus-trapping container, either disable tooltips or ensure adjacent text labels provide sufficient context.
Checklist
-
npx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheet - CSS variables in globals.css (light + dark) + Tailwind config
-
SidebarProviderwraps app withopen/onOpenChange+ localStorage -
Sidebar collapsible="icon" className="border-r group/sidebar" -
ExpandButton:hidden group-hover/sidebar:flex, same size as avatar -
CollapseToggle:PanelLeftOpen rotate-180, conditional render - Avatar:
group-hover/sidebar:hiddenwhen collapsed - All nav items use
SidebarMenuButton tooltip={label} - Group labels use
SidebarGroupLabel(auto-hides) - Collapsible sections use
Collapsible+SidebarMenuSub - Footer widgets: collapsed=icon+tooltip, expanded=full content
-
SidebarRailfor edge hover toggle -
SidebarInset className="overflow-y-auto h-dvh" - Loading skeleton matches sidebar width (
w-64) - Mobile renders as Sheet (automatic)
- Keyboard shortcut: ⌘B / Ctrl+B (automatic)
More from blink-new/claude
seo-article-writing
A comprehensive workflow for creating high-ranking SEO blog articles with keyword research, competitive analysis, AI-generated unique images, and optimized content structure
69pg-boss
Implement reliable PostgreSQL-based job queues with PG Boss. Use when implementing background jobs, scheduled tasks, cron-like functionality, task rollover, or email notifications in Node.js/TypeScript projects.
57kanban-dnd
Build world-class kanban board drag-and-drop with @dnd-kit. Linear-quality UX with proper collision detection, smooth animations, and visual feedback
57datafast
Accelerate adoption of DataFast analytics across any stack by codifying the installation, attribution, event, proxy, and API patterns that drive reliable conversion intelligence
54wysiwyg-editor
Build production-grade WYSIWYG editors using Tiptap v3 with proper markdown-style formatting, instant rendering, and bullet/numbered list support
51team-saas
Build production-grade multi-tenant SaaS applications with team workspaces, member invitation, authentication, and modern UI
51