saas-sidebar
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSaaS Collapsible Sidebar
SaaS可折叠侧边栏
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.
使用shadcn/ui Sidebar组件系统构建一个精致的可折叠侧边栏。涵盖所有细节:图标模式居中、悬停切换展开按钮、自动提示框、键盘快捷键、移动端Sheet组件、状态持久化、加载骨架屏。
When to Use
适用场景
- SaaS dashboard with sidebar navigation
- Collapsible/minimizable sidebar (icon-only mode)
- Responsive layout with mobile sheet overlay
- 带侧边栏导航的SaaS仪表板
- 可折叠/最小化侧边栏(仅图标模式)
- 适配移动端Sheet覆盖层的响应式布局
Quick Start
快速开始
bash
npx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheetThis generates (~770 lines) with ALL sidebar primitives. Do NOT build a custom .
components/ui/sidebar.tsx<aside>bash
npx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheet此命令会生成包含所有侧边栏基础组件的(约770行代码)。请勿自行构建自定义组件。
components/ui/sidebar.tsx<aside>Architecture
架构设计
How the Layout Works (Dual-Div Trick)
布局工作原理(双Div技巧)
The component renders two divs on desktop:
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 │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────┘Both divs transition width together: . The spacer ensures the main content never overlaps the sidebar.
transition-[width] duration-200 ease-linearSidebar┌──────────────────────────────────────────────┐
│ SidebarProvider (flex容器,min-h-svh) │
│ │
│ ┌─ 侧边栏外层div ──────────────────┐ │
│ │ [Spacer div] ← 预留宽度 │ │
│ │ relative w-[--sidebar-width] │ │
│ │ (将SidebarInset向右推动) │ │
│ │ │ │
│ │ [Fixed div] ← 实际侧边栏 │ │
│ │ fixed inset-y-0 z-10 │ │
│ │ w-[--sidebar-width] │ │
│ │ (包含子元素) │ │
│ └───────────────────────────────────────┘ │
│ │
│ ┌─ SidebarInset (主内容区) ────────────────┐ │
│ │ flex-1 overflow-y-auto h-dvh │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────┘两个div会同步过渡宽度:。Spacer确保主内容区永远不会与侧边栏重叠。
transition-[width] duration-200 ease-linearWidth Constants (CSS Variables)
宽度常量(CSS变量)
Set by as inline CSS custom properties:
SidebarProvider| State | Variable | Value |
|---|---|---|
| Expanded | | |
| Collapsed | | |
| Mobile | | |
由以内联CSS自定义属性的形式设置:
SidebarProvider| 状态 | 变量名称 | 取值 |
|---|---|---|
| 展开状态 | | |
| 折叠状态 | | |
| 移动端 | | |
State Context
状态上下文
typescript
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 . Never pass as prop.
useSidebar()collapsedtypescript
type SidebarContextProps = {
state: "expanded" | "collapsed" // 由open状态派生
open: boolean // true = 展开状态
setOpen: (open: boolean) => void
openMobile: boolean // 独立的移动端Sheet状态
setOpenMobile: (open: boolean) => void
isMobile: boolean // 屏幕宽度 < 768px
toggleSidebar: () => void // 智能切换:适配移动端或桌面端逻辑
}可通过在任意位置访问。请勿将作为props传递。
useSidebar()collapsedData Attribute Styling (No Prop Drilling)
数据属性样式(避免Props透传)
The outer div sets data attributes that children react to via Tailwind group selectors:
Sidebarhtml
<div data-state="collapsed" data-collapsible="icon" data-variant="sidebar" data-side="left">Key selectors and what they do:
css
/* 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外层div会设置数据属性,子元素通过Tailwind的group选择器响应这些属性:
Sidebarhtml
<div data-state="collapsed" data-collapsible="icon" data-variant="sidebar" data-side="left">关键选择器及其作用:
css
/* 折叠状态下,强制菜单按钮为32×32px的居中正方形 */
group-data-[collapsible=icon]:!size-8
group-data-[collapsible=icon]:!p-2
/* 平滑隐藏文本标签(负外边距向上移动,透明度渐变消失) */
group-data-[collapsible=icon]:-mt-8
group-data-[collapsible=icon]:opacity-0
/* 折叠状态下完全隐藏子菜单、组操作按钮和徽章 */
group-data-[collapsible=icon]:hidden
/* 防止48px宽的折叠列出现水平滚动条 */
group-data-[collapsible=icon]:overflow-hiddenPeer Coordination (Sidebar ↔ Main Content)
组件协同(侧边栏 ↔ 主内容区)
The sidebar outer div has . uses peer selectors:
group peerSidebarInsettsx
// 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"侧边栏外层div带有类。使用peer选择器:
group peerSidebarInsettsx
// SidebarInset针对inset变体响应侧边栏状态
"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. uses CVA variants:
SidebarMenuButtontypescript
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 (48px) wide with
3rem(8px each side) = 32px usablep-2 - Button forced to (32px) with
!size-8(8px padding) = icon at center!p-2 - clips any text that hasn't faded yet
overflow-hidden - Icons have = always 16×16, never compressed
[&>svg]:size-4 [&>svg]:shrink-0
Size for header:
"lg"- (48px) gives room for two-line text (name + subtitle)
h-12 - removes padding so the h-7 w-7 avatar fits cleanly
group-data-[collapsible=icon]:!p-0
这是最重要的细节。使用CVA变体:
SidebarMenuButtontypescript
const sidebarMenuButtonVariants = cva(
// 基础样式:flex行布局,间距2,溢出隐藏,圆角md,内边距2
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm " +
// 自动截断最后一个span(标签文本)
"[&>span:last-child]:truncate " +
// 图标:始终16×16,不收缩
"[&>svg]:size-4 [&>svg]:shrink-0 " +
// 折叠状态:强制为32×32正方形,图标居中
"group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 " +
// 仅针对宽度、高度、内边距过渡(而非所有属性)
"transition-[width,height,padding] " +
// 激活状态
"data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium",
{
variants: {
size: {
default: "h-8 text-sm", // 32px — 导航项
sm: "h-7 text-xs", // 28px — 紧凑样式
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", // 48px — 头部(工作区切换器)
},
},
}
)折叠状态下完美居中的原因:
- 容器宽度为(48px),内边距
3rem(每边8px)= 可用空间32pxp-2 - 按钮被强制设置为(32px),内边距
!size-8(8px)= 图标居中!p-2 - 会裁剪尚未完全消失的文本
overflow-hidden - 图标设置了= 始终保持16×16,不会被压缩
[&>svg]:size-4 [&>svg]:shrink-0
头部使用尺寸:
"lg"- (48px)为两行文本(名称 + 副标题)预留空间
h-12 - 移除内边距,使h-7 w-7的头像能完美适配
group-data-[collapsible=icon]:!p-0
Built-in Tooltip System
内置提示框系统
SidebarMenuButtontooltiptsx
<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 with auto-visibility:
<Tooltip>tsx
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile} // only show when collapsed + desktop
/>TooltipProvider delayDuration={0}SidebarProviderSidebarMenuButtontooltiptsx
<SidebarMenuButton asChild isActive={isActive} tooltip="Home">
<Link href="/"><Home className="h-4 w-4" /><span>首页</span></Link>
</SidebarMenuButton>内部会自动将按钮包裹在中,并自动控制可见性:
<Tooltip>tsx
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile} // 仅在折叠状态+桌面端显示
/>SidebarProviderTooltipProvider delayDuration={0}The asChild
/ Slot Pattern
asChildasChild
/ Slot模式
asChildEvery component supports (Radix Slot). When true, it merges its props into the child element instead of rendering a wrapper. This is why this works:
asChildtsx
// SidebarMenuButton renders as <Link> not <button><Link>
<SidebarMenuButton asChild tooltip="Home">
<Link href="/">...</Link>
</SidebarMenuButton>所有组件都支持(Radix Slot)。设置为true时,会将组件的props合并到子元素中,而非渲染一个包裹层。这也是以下代码可行的原因:
asChildtsx
// SidebarMenuButton会渲染为<Link>,而非<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折叠状态下,悬停在侧边栏任意位置时,头部的头像会替换为展开按钮:
折叠状态(闲置): [组织头像] ← 仅图标,7×7
折叠状态(悬停): [展开按钮] ← 侧边栏悬停时替换头像
展开状态: [组织切换器 ——— 折叠按钮] ← 完整行Implementation
实现代码
tsx
<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>tsx
<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>ExpandButton
展开按钮(ExpandButton)
tsx
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:
- — invisible by default, appears when sidebar hovered
hidden group-hover/sidebar:flex - — matches the org avatar exactly (zero layout shift)
h-7 w-7 - — prevents the click from reaching the PopoverTrigger behind it
e.stopPropagation()
tsx
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">展开侧边栏</TooltipContent>
</Tooltip>
)
}关键类名:
- — 默认不可见,侧边栏悬停时显示
hidden group-hover/sidebar:flex - — 与组织头像尺寸完全一致(无布局偏移)
h-7 w-7 - — 防止点击事件触发后方的PopoverTrigger
e.stopPropagation()
CollapseToggle
折叠切换按钮(CollapseToggle)
tsx
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 icon with — not a separate icon.
PanelLeftOpenrotate-180PanelLeftClosetsx
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">收起侧边栏</TooltipContent>
</Tooltip>
)
}关键:使用同一个图标,通过实现折叠图标效果 — 无需使用单独的图标。
PanelLeftOpenrotate-180PanelLeftCloseOrg/Team Avatar (Hides on Hover When Collapsed)
组织/团队头像(折叠状态下悬停时隐藏)
tsx
<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: on both lines keeps them compact within the (size="lg") button.
leading-tighth-12tsx
<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">{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>注意:两行文本都使用,以在(size="lg")的按钮内保持紧凑布局。
leading-tighth-12Navigation Items
导航项
Standard Nav Item
标准导航项
tsx
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.
tsx
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>
)
}折叠状态下:图标居中显示为32×32,文本span被截断为不可见,徽章隐藏(overflow-hidden裁剪),悬停时显示提示框。
Collapsible Nested Section
可折叠嵌套区域
tsx
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]:hiddentsx
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]:hiddenGroup Labels (Auto-Hide Trick)
组标签(自动隐藏技巧)
tsx
<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 (NOT ). This keeps the label in DOM so items below shift up with a smooth instead of a hard jump.
-mt-8 opacity-0display:nonetransition-[margin,opacity] duration-200 ease-lineartsx
<SidebarGroup className="py-1">
<SidebarGroupLabel className="text-[10px] uppercase tracking-wider text-muted-foreground/60 font-medium">
项目
</SidebarGroupLabel>
<SidebarMenu>{/* 导航项 */}</SidebarMenu>
</SidebarGroup>内置的自动隐藏使用(而非)。这样标签仍保留在DOM中,下方的项会通过平滑的向上移动,而非突然跳转。
-mt-8 opacity-0display:nonetransition-[margin,opacity] duration-200 ease-linearInline Action Button (Show on Hover)
内联操作按钮(悬停时显示)
tsx
<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 and uses to appear only on hover. Auto-hidden when collapsed.
absolute right-1md:opacity-0 group-hover/menu-item:opacity-100tsx
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="项目">
<Link href="/projects"><FolderOpen className="h-4 w-4" /><span>项目</span></Link>
</SidebarMenuButton>
<SidebarMenuAction showOnHover>
<Plus className="h-4 w-4" />
</SidebarMenuAction>
</SidebarMenuItem>操作按钮定位为,并使用实现仅悬停时显示。折叠状态下会自动隐藏。
absolute right-1md:opacity-0 group-hover/menu-item:opacity-100Footer Widgets (Collapsed ↔ Expanded Pattern)
底部组件(折叠 ↔ 展开模式)
Footer items must gracefully transform between full content (expanded) and centered icon + tooltip (collapsed).
底部项需在展开状态(完整内容)和折叠状态(居中图标 + 提示框)之间优雅过渡。
Pattern: Early Return for Collapsed
模式:折叠状态提前返回
tsx
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>
)
}tsx
function UsageWidget() {
const { state } = useSidebar()
const collapsed = state === 'collapsed'
// 折叠状态:居中图标 + 提示框
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%额度</TooltipContent>
</Tooltip>
)
}
// 展开状态:完整组件
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个额度</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)
用户行(使用SidebarMenuButton)
tsx
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 so collapsed state gets auto-tooltip. Avatar at fits within the collapsed button.
SidebarMenuButton tooltip=h-6 w-6!size-8tsx
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-8Org/Team Switcher (Popover in Header)
组织/团队切换器(头部弹出层)
Critical: DO NOT wrap PopoverTrigger in Tooltip — breaks click handling.
tsx
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 flips to when collapsed so it doesn't overlap the narrow sidebar.
side"right"重要提示:请勿在内部嵌套 — 会破坏点击事件处理。
PopoverTriggerTooltiptsx
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"
>
{/* 组织列表项 */}
{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" /> 设置
</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" /> 新建工作区
</button>
</PopoverContent>
</Popover>
)
}折叠状态下,弹出层的会切换为,以避免与狭窄的侧边栏重叠。
side"right"SidebarRail (Edge Hover Toggle)
侧边栏边缘触发区(SidebarRail)
tsx
<SidebarRail />An invisible hit area positioned at of the sidebar. On hover, it shows a vertical line (). Clicking toggles the sidebar. Users discover this naturally — it's a secondary toggle alongside the header buttons.
w-4-right-42pxhover:after:bg-sidebar-bordertsx
<SidebarRail />一个不可见的触发区域,定位在侧边栏的位置。悬停时会显示一条的垂直线()。点击可切换侧边栏状态。用户会自然发现这个触发区 — 它是头部按钮之外的二级切换方式。
w-4-right-42pxhover:after:bg-sidebar-borderMobile Behavior
移动端行为
Automatic. The component checks (768px breakpoint) and renders:
SidebaruseIsMobile()- Desktop: with collapse animation
hidden md:block - Mobile: Radix overlay (slide-in from left, with backdrop)
Sheet
toggleSidebar()tsx
const toggleSidebar = () => isMobile ? setOpenMobile(o => !o) : setOpen(o => !o)Mobile trigger in your page header:
tsx
<SidebarTrigger className="md:hidden" /> // PanelLeft icon, h-7 w-7The hook:
useIsMobiletsx
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
}自动适配。组件会通过(768px断点)检测设备,并渲染:
SidebaruseIsMobile()- 桌面端:,带有折叠动画
hidden md:block - 移动端:Radix的覆盖层(从左侧滑入,带有背景遮罩)
Sheet
toggleSidebar()tsx
const toggleSidebar = () => isMobile ? setOpenMobile(o => !o) : setOpen(o => !o)页面头部的移动端触发按钮:
tsx
<SidebarTrigger className="md:hidden" /> // PanelLeft图标,h-7 w-7useIsMobiletsx
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 : ⌘B (Mac) / Ctrl+B (Windows). No configuration needed. Calls .
SidebarProvidertoggleSidebar()已内置在中:⌘B(Mac)/ Ctrl+B(Windows)。无需配置,会自动调用。
SidebarProvidertoggleSidebar()State Persistence
状态持久化
localStorage (instant on mount)
localStorage(挂载时即时恢复)
tsx
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}>tsx
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)
Cookie(服务端渲染,由SidebarProvider内部设置)
tsx
document.cookie = `sidebar_state=${openState}; path=/; max-age=${60 * 60 * 24 * 7}`tsx
document.cookie = `sidebar_state=${openState}; path=/; max-age=${60 * 60 * 24 * 7}`Loading Skeleton (Zero Layout Shift)
加载骨架屏(零布局偏移)
Match sidebar width and element sizes:
tsx
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>
)
}匹配侧边栏宽度和元素尺寸:
tsx
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)
布局(包裹整个应用)
tsx
'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: (dynamic viewport height) is better than on mobile Safari.
h-dvhh-screentsx
'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>
)
}注意:(动态视口高度)比更适配移动端Safari。
h-dvhh-screenSidebar (all sections)
侧边栏(包含所有区域)
tsx
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>
)
}tsx
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="首页" icon={Home} />
<NavItem label="搜索" icon={Search} badge="K" onClick={openSearch} />
</SidebarMenu>
</SidebarGroup>
<SidebarGroup className="py-1">
<SidebarGroupLabel>项目</SidebarGroupLabel>
<SidebarMenu>
<CollapsibleSection label="最近" icon={Clock} items={recentItems} />
<NavItem href="/projects" label="全部项目" icon={FolderOpen} />
<NavItem href="/starred" label="已收藏" icon={Star} />
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="gap-0.5 pb-2">
<SidebarSeparator />
<UsageWidget />
<UserRow />
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}CSS Variables (globals.css)
CSS变量(globals.css)
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.colorsts
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))",
},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配置():
theme.extend.colorsts
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
必须遵循
- on
collapsible="icon"for icon-only collapse<Sidebar> - class on
group/sidebarfor hover detection<Sidebar> - to read state — never prop-drill
useSidebar()collapsed - for auto-tooltips
SidebarMenuButton tooltip={label} - selectors for collapsed styling
group-data-[collapsible=icon]: - Match expand button and avatar sizes exactly ()
h-7 w-7 - on expand button (prevents popover trigger)
e.stopPropagation() - with
PanelLeftOpenfor collapse (one icon, not two)rotate-180 - for multi-line text in header button
leading-tight - on all icons and trailing elements
shrink-0 - on all text that could overflow
truncate - on flex children that contain truncated text
min-w-0 - on all clickable elements
cursor-pointer
- 在上设置
<Sidebar>以启用仅图标折叠模式collapsible="icon" - 在上添加
<Sidebar>类以支持悬停检测group/sidebar - 使用读取状态 — 请勿通过props透传
useSidebar()collapsed - 使用以启用自动提示框
SidebarMenuButton tooltip={label} - 使用选择器实现折叠状态样式
group-data-[collapsible=icon]: - 确保展开按钮和头像尺寸完全匹配()
h-7 w-7 - 在展开按钮上添加(防止触发弹出层)
e.stopPropagation() - 使用图标并通过
PanelLeftOpen实现折叠按钮(使用同一个图标,而非两个)rotate-180 - 头部按钮中的多行文本使用
leading-tight - 所有图标和尾部元素添加
shrink-0 - 所有可能溢出的文本添加
truncate - 包含截断文本的flex子元素添加
min-w-0 - 所有可点击元素添加
cursor-pointer
DO NOT
禁止操作
- Nest inside
TooltiporPopoverTriggerDropdownMenuTrigger - Use — use specific properties (
transition-all)transition-[width] - Build a custom — use the shadcn/ui Sidebar system
<aside> - Use (64px) for collapsed — it's
w-16(48px) via CSS var3rem - Use for group labels — use the
display:nonetrick-mt-8 opacity-0 - Use — use
h-screenfor mobile Safari compatibilityh-dvh - Add yourself — it's already in
TooltipProviderSidebarProvider - Put -wrapped elements inside a
Tooltip/Popovercontent — 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.Dialog
- 在或
PopoverTrigger内部嵌套DropdownMenuTriggerTooltip - 使用— 应使用特定属性(如
transition-all)transition-[width] - 自行构建自定义— 请使用shadcn/ui的Sidebar系统
<aside> - 折叠状态使用(64px) — 应通过CSS变量使用
w-16(48px)3rem - 使用隐藏组标签 — 请使用
display:none技巧-mt-8 opacity-0 - 使用— 请使用
h-screen以适配移动端Safarih-dvh - 自行添加—
TooltipProvider已内置SidebarProvider - 在/
Popover内容中添加包裹Dialog的元素 — Radix的提示框会在聚焦时触发,而非仅悬停。弹出层打开时,焦点会移入内容区,会立即触发第一个可聚焦元素的提示框,即使没有悬停。请查看下方的“聚焦触发提示框问题”。Tooltip
Tooltip-on-Focus Gotcha (Radix)
聚焦触发提示框问题(Radix)
Problem: Radix triggers on both hover AND focus. When you place tooltip-wrapped buttons inside a , opening the popover moves focus into the content, which immediately triggers the tooltip on the first focusable element — even without hovering.
<Tooltip><PopoverContent>This affects any component with tooltips rendered inside:
PopoverContentDialogContentSheetContent- Any container that receives focus on open
Solution: Add a prop to components that contain tooltips, and disable them when used inside focus-trapping containers:
showTooltipstsx
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 ? The delay only affects hover. Focus-triggered tooltips ignore in Radix and fire immediately regardless of the delay value.
delayDurationdelayDurationRule of thumb: If a tooltip-wrapped element appears inside a focus-trapping container, either disable tooltips or ensure adjacent text labels provide sufficient context.
问题: Radix的会在悬停和聚焦时都触发。如果将包裹的按钮放在内部,打开弹出层时焦点会移入内容区,会立即触发第一个可聚焦元素的提示框 — 即使没有悬停。
<Tooltip>Tooltip<PopoverContent>受影响的场景: 任何在以下容器内渲染的带提示框的组件:
PopoverContentDialogContentSheetContent- 任何打开时会获取焦点的容器
解决方案: 为包含提示框的组件添加属性,在聚焦陷阱容器内禁用提示框:
showTooltipstsx
interface ThemeToggleProps {
showTooltips?: boolean // 默认true
}
function ThemeToggle({ showTooltips = true }: ThemeToggleProps) {
const btn = <button aria-label={label}>...</button>
// 弹出层内跳过提示框包裹
if (!showTooltips) return btn
return (
<Tooltip>
<TooltipTrigger asChild>{btn}</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
// 弹出层内使用 — 禁用提示框(标签“主题”已提供足够上下文)
<PopoverContent>
<span>主题</span>
<ThemeToggle showTooltips={false} />
</PopoverContent>
// 头部使用 — 启用提示框(仅图标,需要提示框)
<ThemeToggle showTooltips={true} />为什么不只是增加? 延迟仅对悬停生效。Radix中聚焦触发的提示框会忽略,无论延迟值是多少都会立即触发。
delayDurationdelayDuration经验法则: 如果带提示框的元素出现在聚焦陷阱容器内,请禁用提示框,或确保相邻的文本标签提供足够的上下文。
Checklist
检查清单
-
npx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheet - CSS variables in globals.css (light + dark) + Tailwind config
- wraps app with
SidebarProvider/open+ localStorageonOpenChange -
Sidebar collapsible="icon" className="border-r group/sidebar" - :
ExpandButton, same size as avatarhidden group-hover/sidebar:flex - :
CollapseToggle, conditional renderPanelLeftOpen rotate-180 - Avatar: when collapsed
group-hover/sidebar:hidden - All nav items use
SidebarMenuButton tooltip={label} - Group labels use (auto-hides)
SidebarGroupLabel - Collapsible sections use +
CollapsibleSidebarMenuSub - Footer widgets: collapsed=icon+tooltip, expanded=full content
- for edge hover toggle
SidebarRail -
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)
- 执行
npx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheet - 在globals.css中添加CSS变量(亮色 + 暗色)并配置Tailwind
- 使用包裹应用,设置
SidebarProvider/open+ localStorage持久化onOpenChange - 设置
<Sidebar collapsible="icon" className="border-r group/sidebar" - :
ExpandButton,尺寸与头像一致hidden group-hover/sidebar:flex - :
CollapseToggle,条件渲染PanelLeftOpen rotate-180 - 头像:折叠状态下添加
group-hover/sidebar:hidden - 所有导航项使用
SidebarMenuButton tooltip={label} - 组标签使用(自动隐藏)
SidebarGroupLabel - 可折叠区域使用+
CollapsibleSidebarMenuSub - 底部组件:折叠状态=图标+提示框,展开状态=完整内容
- 添加实现边缘悬停切换
SidebarRail - 设置
SidebarInset className="overflow-y-auto h-dvh" - 加载骨架屏匹配侧边栏宽度()
w-64 - 移动端自动渲染为Sheet组件
- 键盘快捷键:⌘B / Ctrl+B(自动支持)