mcpField
S
saas-sidebar

saas-sidebar

github.com/blink-new设计与 UI99 次安装
代码仓库

关于 saas-sidebar

saas-sidebar 是基于 shadcn/ui Sidebar 组件系统构建可折叠 SaaS 导航栏的实现指南,面向仪表盘界面开发。内容涵盖图标模式、移动端 Sheet、键盘切换与状态持久化。

主要功能

  1. 01图标模式折叠布局
  2. 02悬停提示与展开按钮
  3. 03移动端 Sheet 覆盖层
  4. 04⌘B / Ctrl+B 键盘切换
  5. 05localStorage 与 Cookie 状态保存
  6. 06加载骨架屏

适用场景

  1. 01为 SaaS 仪表盘搭建侧边导航
  2. 02实现桌面端图标折叠模式
  3. 03为移动端提供侧边栏覆盖层
  4. 04保留用户的侧边栏展开状态

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/blink-new/claude --skill saas-sidebar

安装后在 Claude Code 中输入/saas-sidebar确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

assets/components/
app-layout.tsx
app-sidebar.tsx
assets/config/
sidebar-css-vars.css

详细介绍

SaaS 可折叠侧边栏

使用 shadcn/ui Sidebar 组件系统 构建精致的可折叠侧边栏。涵盖所有细节:图标模式居中、悬停切换展开按钮、自动提示框、键盘快捷键、移动端 Sheet、状态持久化、加载骨架屏。

适用场景

  • 带侧边栏导航的 SaaS 仪表板
  • 可折叠/最小化的侧边栏(仅图标模式)
  • 带移动端 Sheet 覆盖层的响应式布局

快速开始

npx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheet

这会生成包含所有侧边栏基础组件的 components/ui/sidebar.tsx(约 770 行)。不要自行构建 <aside>。


架构

布局的工作原理(双 div 技巧)

在桌面端,Sidebar 组件会渲染 两个 div:

┌──────────────────────────────────────────────┐
│ 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         │    │
│  └───────────────────────────────────────┘    │
└──────────────────────────────────────────────┘

两个 div 的宽度会一起过渡:transition-[width] duration-200 ease-linear。占位元素可确保主内容永远不会与侧边栏重叠。

宽度常量(CSS 变量)

由 SidebarProvider 以内联 CSS 自定义属性的形式设置:

状态变量值
展开--sidebar-width16rem (256px)
折叠--sidebar-width-icon3rem (48px)
移动端--sidebar-width18rem (288px)
状态上下文
type SidebarContextProps = {
  state: "expanded" | "collapsed"  // derived from open
  open: boolean                     // true = expanded
  setOpen: (open: boolean) => void
  openMobile: boolean               // separate mobile Sheet state
  setOpenMobile: (open: boolean) => void
  isMobile: boolean                 // < 768px
  toggleSidebar: () => void         // smart: routes to mobile or desktop
}

可通过 useSidebar() 在任何位置访问。绝不要将 collapsed 作为 prop 传递。

数据属性样式(无需逐层传递 prop)

外层 Sidebar div 会设置数据属性,子元素通过 Tailwind group 选择器响应这些属性:

<div data-state="collapsed" data-collapsible="icon" data-variant="sidebar" data-side="left">

关键选择器及其作用:

/* Force menu buttons to 32×32px centered squares when collapsed */
group-data-[collapsible=icon]:!size-8
group-data-[collapsible=icon]:!p-2

/* Hide text labels smoothly (negative margin pulls up, opacity fades) */
group-data-[collapsible=icon]:-mt-8
group-data-[collapsible=icon]:opacity-0

/* Hard-hide sub-menus, group actions, badges when collapsed */
group-data-[collapsible=icon]:hidden

/* Prevent horizontal scrollbar in 48px-wide collapsed column */
group-data-[collapsible=icon]:overflow-hidden
Peer 协调(侧边栏 ↔ 主内容)

侧边栏外层 div 使用 group peer。SidebarInset 使用 peer 选择器:

// 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"

居中技巧(图标如何完美对齐)

这是最重要的细节。SidebarMenuButton 使用 CVA 变体:

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)
      },
    },
  }
)

折叠时所有内容都能居中的原因:

  • 容器宽度为 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 头像恰好适配
内置提示框系统

SidebarMenuButton 有一个 tooltip prop,无需手动包裹:

<SidebarMenuButton asChild isActive={isActive} tooltip="Home">
  <Link href="/"><Home className="h-4 w-4" /><span>Home</span></Link>
</SidebarMenuButton>

内部会用 <Tooltip> 包裹按钮,并自动控制显示:

<TooltipContent
  side="right"
  align="center"
  hidden={state !== "collapsed" || isMobile}  // only show when collapsed + desktop
/>

SidebarProvider 层级设置了 TooltipProvider delayDuration={0},因此提示框会立即显示。

asChild / Slot 模式

每个组件都支持 asChild(Radix Slot)。启用后,它会将自身的 props 合并到子元素中,而不是渲染一个包装元素。因此下面的写法可行:

// SidebarMenuButton renders as <Link> not <button><Link>
<SidebarMenuButton asChild tooltip="Home">
  <Link href="/">...</Link>
</SidebarMenuButton>

展开/折叠模式

工作原理

折叠时,将鼠标悬停在侧边栏任意位置,标题中的头像就会切换为展开按钮:

Collapsed (idle):    [OrgAvatar]                    ← icon only, 7×7
Collapsed (hover):   [ExpandBtn]                    ← replaces avatar on sidebar hover
Expanded:            [OrgSwitcher ——— CollapseBtn]  ← full row
实现
<Sidebar collapsible="icon" className="border-r group/sidebar">
  <SidebarHeader className="pb-0">
    <SidebarMenu>
      <SidebarMenuItem className="flex items-center gap-1">
        <ExpandButton />           {/* hidden → shows on sidebar hover */}
        <OrgSwitcher />            {/* avatar hides on sidebar hover when collapsed */}
        <CollapseToggle />         {/* early-returns null when collapsed */}
      </SidebarMenuItem>
    </SidebarMenu>
  </SidebarHeader>
ExpandButton
function ExpandButton() {
  const { toggleSidebar, state } = useSidebar()
  if (state !== 'collapsed') return null

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <button
          onClick={(e) => { e.stopPropagation(); toggleSidebar() }}
          className="hidden group-hover/sidebar:flex items-center justify-center h-7 w-7 rounded-md bg-accent text-foreground cursor-pointer hover:bg-accent/80 transition-colors shrink-0"
        >
          <PanelLeftOpen className="h-4 w-4" />
        </button>
      </TooltipTrigger>
      <TooltipContent side="right" align="center">Expand sidebar</TooltipContent>
    </Tooltip>
  )
}

关键类:

  • hidden group-hover/sidebar:flex — 默认不可见,悬停在侧边栏上时显示
  • h-7 w-7 — 与组织头像尺寸完全一致(布局不会发生偏移)
  • e.stopPropagation() — 阻止点击事件传递到其后方的 PopoverTrigger
CollapseToggle
function CollapseToggle() {
  const { toggleSidebar, state } = useSidebar()
  if (state !== 'expanded') return null

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <button
          onClick={toggleSidebar}
          className="h-7 w-7 flex items-center justify-center rounded-md text-muted-foreground/50 hover:text-muted-foreground hover:bg-accent transition-colors cursor-pointer shrink-0"
        >
          <PanelLeftOpen className="h-4 w-4 rotate-180" />
        </button>
      </TooltipTrigger>
      <TooltipContent side="right">Close sidebar</TooltipContent>
    </Tooltip>
  )
}

关键点:使用同一个 PanelLeftOpen 图标并添加 rotate-180,而不是使用单独的 PanelLeftClose 图标。

组织/团队头像(折叠时悬停后隐藏)
<SidebarMenuButton size="lg" className={cn("w-full cursor-pointer", collapsed && "justify-center")}>
  <div className={cn(
    "flex items-center justify-center h-7 w-7 rounded-md bg-primary text-primary-foreground text-xs font-bold shrink-0",
    collapsed && "group-hover/sidebar:hidden"  // ← KEY: hides when sidebar hovered
  )}>
    {initial}
  </div>
  {!collapsed && (
    <>
      <div className="flex-1 min-w-0 text-left">
        <p className="text-sm font-semibold truncate leading-tight">{name}</p>
        <p className="text-[10px] text-muted-foreground leading-tight">{subtitle}</p>
      </div>
      <ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
    </>
  )}
</SidebarMenuButton>

注意:两行都使用 leading-tight,使其在 h-12(size="lg")按钮中保持紧凑。


导航项

标准导航项
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 裁切),悬停时会显示提示框。

可折叠嵌套分区
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。父按钮仍会显示为仅含图标且带提示框的导航项。

分组标签(自动隐藏技巧)
<SidebarGroup className="py-1">
  <SidebarGroupLabel className="text-[10px] uppercase tracking-wider text-muted-foreground/60 font-medium">
    Projects
  </SidebarGroupLabel>
  <SidebarMenu>{/* items */}</SidebarMenu>
</SidebarGroup>

内置的自动隐藏使用 -mt-8 opacity-0(而不是 display:none)。这样标签会保留在 DOM 中,下方的项目会通过平滑的 transition-[margin,opacity] duration-200 ease-linear 向上移动,而不是突然跳动。

行内操作按钮(悬停时显示)
<SidebarMenuItem>
  <SidebarMenuButton asChild tooltip="Projects">
    <Link href="/projects"><FolderOpen className="h-4 w-4" /><span>Projects</span></Link>
  </SidebarMenuButton>
  <SidebarMenuAction showOnHover>
    <Plus className="h-4 w-4" />
  </SidebarMenuAction>
</SidebarMenuItem>

操作按钮通过 absolute right-1 定位,并使用 md:opacity-0 group-hover/menu-item:opacity-100,仅在悬停时显示。折叠时会自动隐藏。


页脚小组件(折叠 ↔ 展开模式)

页脚项目必须能在完整内容(展开)与居中图标 + 工具提示(折叠)之间平滑切换。

模式:折叠时提前返回
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>
  )
}
用户行(使用 SidebarMenuButton)
function UserRow() {
  const { state } = useSidebar()
  const collapsed = state === 'collapsed'

  return (
    <SidebarMenu>
      <SidebarMenuItem>
        <SidebarMenuButton asChild tooltip={userName} className={cn(collapsed && "flex items-center justify-center")}>
          <Link href="/settings" className="flex items-center gap-2 cursor-pointer">
            <Avatar className="h-6 w-6 shrink-0">
              <AvatarImage src={photo} />
              <AvatarFallback className="text-[10px] bg-muted">{initial}</AvatarFallback>
            </Avatar>
            {!collapsed && (
              <>
                <span className="truncate text-sm font-medium">{userName}</span>
                <Settings className="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground/50 hover:text-muted-foreground transition-colors" />
              </>
            )}
          </Link>
        </SidebarMenuButton>
      </SidebarMenuItem>
    </SidebarMenu>
  )
}

使用 SidebarMenuButton tooltip=,使折叠状态自动显示工具提示。尺寸为 h-6 w-6 的头像可以放入尺寸为 !size-8 的折叠按钮中。


组织/团队切换器(页眉中的弹出框)

重要:不要将 PopoverTrigger 包装在 Tooltip 中——这会导致点击处理失效。

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 会切换为 "right",避免与窄侧边栏重叠。


SidebarRail(边缘悬停切换)

<SidebarRail />

一个不可见的 w-4 点击区域,位于侧边栏的 -right-4 处。悬停时会显示一条 2px 的竖线(hover:after:bg-sidebar-border)。点击即可切换侧边栏。用户会自然发现此功能——它是页眉按钮之外的辅助切换方式。


移动端行为

自动处理。 Sidebar 组件会检查 useIsMobile()(768px 断点)并进行以下渲染:

  • 桌面端:使用折叠动画的 hidden md:block
  • 移动端:Radix Sheet 覆盖层(从左侧滑入,并带有背景遮罩)

toggleSidebar() 会切换到相应的行为:

const toggleSidebar = () => isMobile ? setOpenMobile(o => !o) : setOpen(o => !o)

页面页眉中的移动端触发器:

<SidebarTrigger className="md:hidden" />  // PanelLeft icon, h-7 w-7

useIsMobile 钩子:

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
}

键盘快捷键

内置于 SidebarProvider:⌘B(Mac)/ Ctrl+B(Windows)。无需配置。会调用 toggleSidebar()。


状态持久化

localStorage(挂载时立即生效)
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,由 SidebarProvider 内部设置)
document.cookie = `sidebar_state=${openState}; path=/; max-age=${60 * 60 * 24 * 7}`

加载骨架屏(零布局偏移)

保持侧边栏宽度和元素尺寸一致:

function SidebarSkeleton() {
  return (
    <div className="flex min-h-screen">
      <div className="w-64 shrink-0 border-r bg-sidebar p-3 space-y-4">
        <div className="flex items-center gap-2">
          <div className="h-7 w-7 rounded-md bg-muted animate-pulse" />
          <div className="flex-1 space-y-1.5">
            <div className="h-3 w-28 rounded bg-muted animate-pulse" />
            <div className="h-2 w-16 rounded bg-muted animate-pulse" />
          </div>
        </div>
        <div className="space-y-1 pt-2">
          {[...Array(5)].map((_, i) => (
            <div key={i} className="h-8 rounded-md bg-muted/50 animate-pulse" />
          ))}
        </div>
      </div>
      <div className="flex-1 flex items-center justify-center">
        <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
      </div>
    </div>
  )
}

完整组装

布局(包裹应用)
'use client'
import { useState } from 'react'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import { AppSidebar } from './app-sidebar'

const SIDEBAR_KEY = 'sidebar_state'

export function DashboardLayout({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(() => {
    if (typeof window === 'undefined') return true
    const stored = localStorage.getItem(SIDEBAR_KEY)
    return stored === null ? true : stored === 'true'
  })

  const handleOpenChange = (value: boolean) => {
    setOpen(value)
    localStorage.setItem(SIDEBAR_KEY, String(value))
  }

  return (
    <SidebarProvider open={open} onOpenChange={handleOpenChange}>
      <AppSidebar />
      <SidebarInset className="overflow-y-auto h-dvh">
        {children}
      </SidebarInset>
    </SidebarProvider>
  )
}

注意:在移动版 Safari 上,h-dvh(动态视口高度)比 h-screen 更合适。

侧边栏(所有分区)
export function AppSidebar() {
  return (
    <Sidebar collapsible="icon" className="border-r group/sidebar">
      <SidebarHeader className="pb-0">
        <SidebarMenu>
          <SidebarMenuItem className="flex items-center gap-1">
            <ExpandButton />
            <OrgSwitcher />
            <CollapseToggle />
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarHeader>

      <SidebarContent>
        <SidebarGroup className="py-1">
          <SidebarMenu>
            <NavItem href="/dashboard" label="Home" icon={Home} />
            <NavItem label="Search" icon={Search} badge="K" onClick={openSearch} />
          </SidebarMenu>
        </SidebarGroup>

        <SidebarGroup className="py-1">
          <SidebarGroupLabel>Projects</SidebarGroupLabel>
          <SidebarMenu>
            <CollapsibleSection label="Recent" icon={Clock} items={recentItems} />
            <NavItem href="/projects" label="All projects" icon={FolderOpen} />
            <NavItem href="/starred" label="Starred" icon={Star} />
          </SidebarMenu>
        </SidebarGroup>
      </SidebarContent>

      <SidebarFooter className="gap-0.5 pb-2">
        <SidebarSeparator />
        <UsageWidget />
        <UserRow />
      </SidebarFooter>

      <SidebarRail />
    </Sidebar>
  )
}

CSS 变量(globals.css)

:root {
  --sidebar: 0 0% 98%;
  --sidebar-foreground: 240 5.3% 26.1%;
  --sidebar-border: 220 13% 91%;
  --sidebar-accent: 220 14.3% 95.9%;
  --sidebar-accent-foreground: 220.9 39.3% 11%;
  --sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
  --sidebar: 240 5.9% 10%;
  --sidebar-foreground: 240 4.8% 95.9%;
  --sidebar-border: 240 3.7% 15.9%;
  --sidebar-accent: 240 3.7% 15.9%;
  --sidebar-accent-foreground: 240 4.8% 95.9%;
  --sidebar-ring: 217.2 91.2% 59.8%;
}

Tailwind 配置(theme.extend.colors):

sidebar: {
  DEFAULT: "hsl(var(--sidebar))",
  foreground: "hsl(var(--sidebar-foreground))",
  border: "hsl(var(--sidebar-border))",
  accent: "hsl(var(--sidebar-accent))",
  "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
  ring: "hsl(var(--sidebar-ring))",
},

重要规则

应遵循
  • 在 <Sidebar> 上使用 collapsible="icon",实现仅显示图标的折叠模式
  • 在 <Sidebar> 上添加 group/sidebar 类,以检测悬停
  • 使用 useSidebar() 读取状态——不要通过属性逐层传递 collapsed
  • 使用 SidebarMenuButton tooltip={label} 实现自动工具提示
  • 使用 group-data-[collapsible=icon]: 选择器设置折叠状态样式
  • 展开按钮和头像的尺寸必须完全一致(h-7 w-7)
  • 在展开按钮上使用 e.stopPropagation()(防止触发弹出框)
  • 使用带有 rotate-180 的 PanelLeftOpen 实现折叠(使用一个图标,而不是两个)
  • 在页眉按钮中的多行文本上使用 leading-tight
  • 在所有图标和尾随元素上使用 shrink-0
  • 对所有可能溢出的文本使用 truncate
  • 对包含截断文本的 flex 子元素使用 min-w-0
  • 在所有可点击元素上使用 cursor-pointer
不要
  • 将 Tooltip 嵌套在 PopoverTrigger 或 DropdownMenuTrigger 中
  • 使用 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 中
  • 将带有 Tooltip 的元素放入 Popover/Dialog 内容中——Radix 提示框会在获得焦点时触发,而不仅仅是在悬停时触发。弹出框打开时,焦点会移入其内容,并自动在第一个可聚焦元素上触发提示框。请参阅下文的“获得焦点时触发提示框的注意事项”。

获得焦点时触发提示框的注意事项(Radix)

问题: Radix <Tooltip> 在悬停和获得焦点时都会触发。当你将带有提示框的按钮放入 <PopoverContent> 中时,打开弹出框会使焦点移入其内容,从而立即在第一个可聚焦元素上触发提示框,即使没有悬停也会如此。

以下所有内部渲染了提示框的组件都会受到影响:

  • PopoverContent
  • DialogContent
  • SheetContent
  • 任何打开时会获得焦点的容器

解决方案: 为包含提示框的组件添加 showTooltips 属性,并在这些组件用于焦点陷阱容器中时禁用提示框:

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} />

为什么不直接增加 delayDuration? 延迟只会影响悬停。在 Radix 中,由焦点触发的提示框会忽略 delayDuration,无论延迟值是多少,都会立即触发。

经验法则: 如果带有提示框的元素位于焦点陷阱容器中,请禁用提示框,或确保相邻的文本标签能提供足够的上下文。


检查清单

  • npx shadcn@latest add sidebar tooltip avatar popover collapsible separator skeleton sheet
  • 在 globals.css 中设置 CSS 变量(浅色 + 深色)+ Tailwind 配置
  • 使用 open/onOpenChange + localStorage,让 SidebarProvider 包裹应用
  • 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(自动)

评论

支持 Markdown