saas-sidebar

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

SaaS 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 sheet
This generates
components/ui/sidebar.tsx
(~770 lines) with ALL sidebar primitives. Do NOT build a custom
<aside>
.

bash
npx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheet
此命令会生成包含所有侧边栏基础组件的
components/ui/sidebar.tsx
(约770行代码)。请勿自行构建自定义
<aside>
组件。

Architecture

架构设计

How the Layout Works (Dual-Div Trick)

布局工作原理(双Div技巧)

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.
Sidebar
组件在桌面端会渲染两个div
┌──────────────────────────────────────────────┐
│ 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会同步过渡宽度:
transition-[width] duration-200 ease-linear
。Spacer确保主内容区永远不会与侧边栏重叠。

Width Constants (CSS Variables)

宽度常量(CSS变量)

Set by
SidebarProvider
as inline CSS custom properties:
StateVariableValue
Expanded
--sidebar-width
16rem
(256px)
Collapsed
--sidebar-width-icon
3rem
(48px)
Mobile
--sidebar-width
18rem
(288px)
SidebarProvider
以内联CSS自定义属性的形式设置:
状态变量名称取值
展开状态
--sidebar-width
16rem
(256px)
折叠状态
--sidebar-width-icon
3rem
(48px)
移动端
--sidebar-width
18rem
(288px)

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
useSidebar()
. Never pass
collapsed
as prop.
typescript
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         // 智能切换:适配移动端或桌面端逻辑
}
可通过
useSidebar()
在任意位置访问。请勿将
collapsed
作为props传递。

Data Attribute Styling (No Prop Drilling)

数据属性样式(避免Props透传)

The outer
Sidebar
div sets data attributes that children react to via Tailwind group selectors:
html
<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
外层
Sidebar
div会设置数据属性,子元素通过Tailwind的group选择器响应这些属性:
html
<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-hidden

Peer Coordination (Sidebar ↔ Main Content)

组件协同(侧边栏 ↔ 主内容区)

The sidebar outer div has
group peer
.
SidebarInset
uses peer selectors:
tsx
// 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带有
group peer
类。
SidebarInset
使用peer选择器:
tsx
// 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.
SidebarMenuButton
uses CVA variants:
typescript
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 with
    p-2
    (8px each side) = 32px usable
  • Button forced to
    !size-8
    (32px) with
    !p-2
    (8px padding) = icon at center
  • overflow-hidden
    clips 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-0
    removes padding so the h-7 w-7 avatar fits cleanly
这是最重要的细节。
SidebarMenuButton
使用CVA变体:
typescript
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 — 头部(工作区切换器)
      },
    },
  }
)
折叠状态下完美居中的原因:
  • 容器宽度为
    3rem
    (48px),内边距
    p-2
    (每边8px)= 可用空间32px
  • 按钮被强制设置为
    !size-8
    (32px),内边距
    !p-2
    (8px)= 图标居中
  • overflow-hidden
    会裁剪尚未完全消失的文本
  • 图标设置了
    [&>svg]:size-4 [&>svg]:shrink-0
    = 始终保持16×16,不会被压缩
头部使用
"lg"
尺寸:
  • h-12
    (48px)为两行文本(名称 + 副标题)预留空间
  • group-data-[collapsible=icon]:!p-0
    移除内边距,使h-7 w-7的头像能完美适配

Built-in Tooltip System

内置提示框系统

SidebarMenuButton
has a
tooltip
prop. NO manual wrapping needed:
tsx
<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:
tsx
<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.
SidebarMenuButton
带有
tooltip
属性,无需手动包裹:
tsx
<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}  // 仅在折叠状态+桌面端显示
/>
SidebarProvider
已设置
TooltipProvider delayDuration={0}
= 提示框即时显示。

The
asChild
/ Slot Pattern

asChild
/ Slot模式

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:
tsx
// SidebarMenuButton renders as <Link> not <button><Link>
<SidebarMenuButton asChild tooltip="Home">
  <Link href="/">...</Link>
</SidebarMenuButton>

所有组件都支持
asChild
(Radix Slot)。设置为true时,会将组件的props合并到子元素中,而非渲染一个包裹层。这也是以下代码可行的原因:
tsx
// 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:
  • hidden group-hover/sidebar:flex
    — invisible by default, appears when sidebar hovered
  • h-7 w-7
    — matches the org avatar exactly (zero layout shift)
  • e.stopPropagation()
    — prevents the click from reaching the PopoverTrigger behind it
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
    — 与组织头像尺寸完全一致(无布局偏移)
  • e.stopPropagation()
    — 防止点击事件触发后方的PopoverTrigger

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
PanelLeftOpen
icon with
rotate-180
— not a separate
PanelLeftClose
icon.
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">收起侧边栏</TooltipContent>
    </Tooltip>
  )
}
关键:使用同一个
PanelLeftOpen
图标,通过
rotate-180
实现折叠图标效果 — 无需使用单独的
PanelLeftClose
图标。

Org/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:
leading-tight
on both lines keeps them compact within the
h-12
(size="lg") button.

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"  // ← 关键:侧边栏悬停时隐藏
  )}>
    {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-tight
,以在
h-12
(size="lg")的按钮内保持紧凑布局。

Navigation 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>
  )
}
SidebarMenuSub
auto-hides when collapsed:
group-data-[collapsible=icon]:hidden
. The parent button still shows as an icon-only tooltip item.
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>
  )
}
SidebarMenuSub
在折叠状态下会自动隐藏:
group-data-[collapsible=icon]:hidden
。父按钮仍会以仅图标+提示框的形式显示。

Group 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
-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.
tsx
<SidebarGroup className="py-1">
  <SidebarGroupLabel className="text-[10px] uppercase tracking-wider text-muted-foreground/60 font-medium">
    项目
  </SidebarGroupLabel>
  <SidebarMenu>{/* 导航项 */}</SidebarMenu>
</SidebarGroup>
内置的自动隐藏使用
-mt-8 opacity-0
(而非
display:none
)。这样标签仍保留在DOM中,下方的项会通过平滑的
transition-[margin,opacity] duration-200 ease-linear
向上移动,而非突然跳转。

Inline 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
absolute right-1
and uses
md:opacity-0 group-hover/menu-item:opacity-100
to appear only on hover. Auto-hidden when collapsed.

tsx
<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-1
,并使用
md:opacity-0 group-hover/menu-item:opacity-100
实现仅悬停时显示。折叠状态下会自动隐藏。

Footer 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
SidebarMenuButton tooltip=
so collapsed state gets auto-tooltip. Avatar at
h-6 w-6
fits within the
!size-8
collapsed button.

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>
  )
}
使用
SidebarMenuButton tooltip=
属性,折叠状态下会自动显示提示框。头像设置为
h-6 w-6
,以适配
!size-8
的折叠按钮。

Org/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
side
flips to
"right"
when collapsed so it doesn't overlap the narrow sidebar.

重要提示:请勿在
PopoverTrigger
内部嵌套
Tooltip
— 会破坏点击事件处理。
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"
      >
        {/* 组织列表项 */}
        {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
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.

tsx
<SidebarRail />
一个不可见的
w-4
触发区域,定位在侧边栏的
-right-4
位置。悬停时会显示一条
2px
的垂直线(
hover:after:bg-sidebar-border
)。点击可切换侧边栏状态。用户会自然发现这个触发区 — 它是头部按钮之外的二级切换方式。

Mobile Behavior

移动端行为

Automatic. The
Sidebar
component checks
useIsMobile()
(768px breakpoint) and renders:
  • Desktop:
    hidden md:block
    with collapse animation
  • Mobile: Radix
    Sheet
    overlay (slide-in from left, with backdrop)
toggleSidebar()
routes to the correct behavior:
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-7
The
useIsMobile
hook:
tsx
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
}

自动适配
Sidebar
组件会通过
useIsMobile()
(768px断点)检测设备,并渲染:
  • 桌面端:
    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-7
useIsMobile
钩子:
tsx
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()
.

已内置在
SidebarProvider
中:⌘B(Mac)/ Ctrl+B(Windows)。无需配置,会自动调用
toggleSidebar()

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:
h-dvh
(dynamic viewport height) is better than
h-screen
on mobile Safari.
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>
  )
}
注意:
h-dvh
(动态视口高度)比
h-screen
更适配移动端Safari。

Sidebar (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.colors
):
ts
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.colors
):
ts
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 collapse
  • group/sidebar
    class on
    <Sidebar>
    for hover detection
  • useSidebar()
    to read state — never prop-drill
    collapsed
  • SidebarMenuButton tooltip={label}
    for auto-tooltips
  • group-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)
  • PanelLeftOpen
    with
    rotate-180
    for collapse (one icon, not two)
  • leading-tight
    for multi-line text in header button
  • shrink-0
    on all icons and trailing elements
  • truncate
    on all text that could overflow
  • min-w-0
    on flex children that contain truncated text
  • cursor-pointer
    on all clickable elements
  • <Sidebar>
    上设置
    collapsible="icon"
    以启用仅图标折叠模式
  • <Sidebar>
    上添加
    group/sidebar
    类以支持悬停检测
  • 使用
    useSidebar()
    读取状态 — 请勿通过props透传
    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
    Tooltip
    inside
    PopoverTrigger
    or
    DropdownMenuTrigger
  • 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's
    3rem
    (48px) via CSS var
  • Use
    display:none
    for group labels — use the
    -mt-8 opacity-0
    trick
  • Use
    h-screen
    — use
    h-dvh
    for mobile Safari compatibility
  • Add
    TooltipProvider
    yourself — it's already in
    SidebarProvider
  • Put
    Tooltip
    -wrapped elements inside a
    Popover
    /
    Dialog
    content — 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.

  • PopoverTrigger
    DropdownMenuTrigger
    内部嵌套
    Tooltip
  • 使用
    transition-all
    — 应使用特定属性(如
    transition-[width]
  • 自行构建自定义
    <aside>
    — 请使用shadcn/ui的Sidebar系统
  • 折叠状态使用
    w-16
    (64px) — 应通过CSS变量使用
    3rem
    (48px)
  • 使用
    display:none
    隐藏组标签 — 请使用
    -mt-8 opacity-0
    技巧
  • 使用
    h-screen
    — 请使用
    h-dvh
    以适配移动端Safari
  • 自行添加
    TooltipProvider
    SidebarProvider
    已内置
  • Popover
    /
    Dialog
    内容中添加包裹
    Tooltip
    的元素 — Radix的提示框会在聚焦时触发,而非仅悬停。弹出层打开时,焦点会移入内容区,会立即触发第一个可聚焦元素的提示框,即使没有悬停。请查看下方的“聚焦触发提示框问题”。

Tooltip-on-Focus Gotcha (Radix)

聚焦触发提示框问题(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:
  • PopoverContent
  • DialogContent
  • SheetContent
  • 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:
tsx
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.

问题: Radix的
<Tooltip>
会在悬停和聚焦时都触发。如果将包裹
Tooltip
的按钮放在
<PopoverContent>
内部,打开弹出层时焦点会移入内容区,会立即触发第一个可聚焦元素的提示框 — 即使没有悬停。
受影响的场景: 任何在以下容器内渲染的带提示框的组件:
  • PopoverContent
  • DialogContent
  • SheetContent
  • 任何打开时会获取焦点的容器
解决方案: 为包含提示框的组件添加
showTooltips
属性,在聚焦陷阱容器内禁用提示框:
tsx
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} />
为什么不只是增加
delayDuration
延迟仅对悬停生效。Radix中聚焦触发的提示框会忽略
delayDuration
,无论延迟值是多少都会立即触发。
经验法则: 如果带提示框的元素出现在聚焦陷阱容器内,请禁用提示框,或确保相邻的文本标签提供足够的上下文。

Checklist

检查清单

  • npx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheet
  • CSS variables in globals.css (light + dark) + Tailwind config
  • SidebarProvider
    wraps app with
    open
    /
    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:hidden
    when 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
  • SidebarRail
    for 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)
  • 执行
    npx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheet
  • 在globals.css中添加CSS变量(亮色 + 暗色)并配置Tailwind
  • 使用
    SidebarProvider
    包裹应用,设置
    open
    /
    onOpenChange
    + localStorage持久化
  • 设置
    <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
    (自动隐藏)
  • 可折叠区域使用
    Collapsible
    +
    SidebarMenuSub
  • 底部组件:折叠状态=图标+提示框,展开状态=完整内容
  • 添加
    SidebarRail
    实现边缘悬停切换
  • 设置
    SidebarInset className="overflow-y-auto h-dvh"
  • 加载骨架屏匹配侧边栏宽度(
    w-64
  • 移动端自动渲染为Sheet组件
  • 键盘快捷键:⌘B / Ctrl+B(自动支持)