shadcn/ui 组件组合规则详解:从 Group 嵌套、浮层选型到禁用自定义标记的完整实践
本篇技术指南围绕 shadcn/ui 仓库中 composition.md 这份组件组合(Composition)规则文档展开,系统讲解 13 条“始终强制执行”的组合约定:Item 必须嵌套在 Group 中、浮层组件如何选型、Toast 如何跟随项目 base、Button 加载态为何不用 isPending 等。读完后你将掌握在 shadcn/ui 项目中写出结构正确、可访问、可复用代码的组合范式,并能直接对照仓库中的注册表源码验证每条规则的实现依据。
规则文档在项目中的位置
composition.md 是 shadcn Agent 技能(SKILL.md)“Critical Rules”(关键规则)体系的组成部分。在 SKILL.md 中,这份文档被两次引用:
- Component Structure(组件结构):覆盖 Group 嵌套、Dialog/Sheet/Drawer 的 Title、Card 完整组合、Button 加载态、TabsTrigger 嵌套、AvatarFallback;
- Use Components, Not Custom Markup(用组件而非自定义标记):覆盖 Alert、Empty、Toast、Separator、Skeleton、Badge。
规则文档自身以“Incorrect / Correct”代码对的格式给出反例与正例,属于可被 Agent 与人类开发者共同遵循的强制约定,而非建议性风格。仓库中的注册表组件源码(registry/bases/*、registry/new-york-v4/*)则提供了每条规则落地的实现证据。
Items 必须嵌套在 Group 组件内
第一条规则是:永远不要将 item 直接渲染在内容容器内,必须包一层对应的 Group。
错误写法:
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectContent>
正确写法:
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
规则文档给出了完整的 Item → Group 映射表,适用于所有基于分组的组件:
| Item | Group |
|---|---|
SelectItem、SelectLabel |
SelectGroup |
DropdownMenuItem、DropdownMenuLabel、DropdownMenuSub |
DropdownMenuGroup |
MenubarItem |
MenubarGroup |
ContextMenuItem |
ContextMenuGroup |
CommandItem |
CommandGroup |
MessageScrollerItem |
MessageScrollerContent |
Message(连续、同一发送者) |
MessageGroup |
Bubble(堆叠) |
BubbleGroup |
Attachment(同一行) |
AttachmentGroup |
对于聊天组件,嵌套顺序是固定的:MessageScrollerProvider → MessageScroller → MessageScrollerViewport → MessageScrollerContent → MessageScrollerItem,更细的规则见 chat.md。
仓库中的实现佐证了这一结构:聊天滚动组件的完整源码位于 message-scroller.tsx,其中 MessageScrollerItem 与 MessageScrollerContent 作为独立导出的组合子部件存在,说明“Item 必须在 Content/Group 内”不是文档约定,而是组件本身的结构设计。
提示框(Callout)统一使用 Alert
页面中需要引起用户注意的提示信息,一律使用 Alert,不要手写带边框的 div:
<Alert>
<AlertTitle>Warning</AlertTitle>
<AlertDescription>Something needs attention.</AlertDescription>
</Alert>
从注册表源码可以确认其组合结构与能力。以 alert.tsx 为例:
Alert根元素渲染为div,携带role="alert"与data-slot="alert",并支持default与destructive两个variant,通过 CVA(class-variance-authority)管理样式;AlertTitle与AlertDescription通过col-start-2等 CSS Grid 布局类实现标题/描述与图标的两列排布(根类中的grid-cols-[0_1fr]与has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr]表明传入子级 SVG 时自动出现图标列);destructive变体会将[&>svg]与描述文本一并染为 destructive 色。
这意味着使用 Alert 时,语义角色、变体切换、图标排布全部由组件承担,无需任何额外标记。
空状态(Empty state)使用 Empty 组件
空列表、无数据等场景不要拼凑自定义标记,统一使用 Empty 组合结构:
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon"><FolderIcon /></EmptyMedia>
<EmptyTitle>No projects yet</EmptyTitle>
<EmptyDescription>Get started by creating a new project.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button>Create Project</Button>
</EmptyContent>
</Empty>
结构上分为两部分:EmptyHeader 承载媒体(EmptyMedia,示例中使用 variant="icon")、标题与描述;EmptyContent 承载行动按钮等后续操作。该组件在注册表中以 empty.tsx 提供(Base UI 版),在 new-york-v4/ui/empty.tsx 中亦有对应实现,说明它是跨 base 的标准组件。
Toast 通知跟随项目的 base 选择
Toast 的实现取决于项目的 base 字段(可通过 npx shadcn@latest info 查看):
Base UI 项目使用 toast 组件,以命令式 API 添加通知:
import { toast } from "@/components/ui/toast"
toast.add({
title: "Changes saved.",
})
Radix 与 React Aria 项目则使用 Sonner:
import { toast } from "sonner"
toast.success("Changes saved.")
toast.error("Something went wrong.")
toast("File deleted.", {
action: { label: "Undo", onClick: () => undoDelete() },
})
仓库源码印证了两条路线的共存:
- Base UI 路线的完整实现见 toast.tsx:文件顶部通过
ToastPrimitive.createToastManager()创建toast单例,并导出toast、useToastManager、createToastManager,这正是文档中toast.add(...)命令式调用的来源;Toaster组件内部组合了ToastProvider→ToastPortal→ToastViewport→ToastList,其中ToastList用useToastManager()读取toasts并逐条渲染Toast+ToastContent+ToastIcon/ToastTitle/ToastDescription/ToastAction/ToastClose。ToastIcon还支持success、info、warning、error、loading五种类型的图标(toast.tsx#L142-L224),示例可参考 toast-example.tsx; - Radix/Aria 路线见 sonner.tsx,用法示例见 sonner-example.tsx。
因此,在写 Toast 前先确认项目 base,混用(如在 Base UI 项目里引入 Sonner)会破坏与主题变量的联动。
浮层组件选型:Dialog、Sheet、Drawer 与轻信息浮层
规则文档给出了六类浮层场景的选型表:
| 使用场景 | 组件 |
|---|---|
| 需要输入的重点任务 | Dialog |
| 破坏性操作的确认 | AlertDialog |
| 带详情或筛选器的侧边面板 | Sheet |
| 移动优先的底部面板 | Drawer |
| 悬停时的快速信息 | HoverCard |
| 点击后的小块上下文内容 | Popover |
这些组件在注册表中均有对应实现,例如 dialog.tsx、sheet.tsx、drawer.tsx、alert-dialog.tsx、hover-card.tsx,选型时按“交互语义”而不是“视觉相似度”判断。
Dialog、Sheet、Drawer 必须带 Title
DialogTitle、SheetTitle、DrawerTitle 是可访问性(accessibility)必需项,必须始终存在。如果需要视觉上隐藏标题,使用 className="sr-only",而不是直接省略:
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Update your profile.</DialogDescription>
</DialogHeader>
...
</DialogContent>
这条规则的原因在于浮层组件的无障碍实现依赖标题节点作为 aria-labelledby 的指向目标;省略标题会导致屏幕阅读器无法获知浮层用途。sr-only 方案在保留 DOM 结构的同时实现了视觉隐藏,两者兼得。
Card 使用完整组合,不要把内容全塞进 CardContent
规则要求使用完整的 Card 组合结构,而不是把所有东西堆进 CardContent:
<Card>
<CardHeader>
<CardTitle>Team Members</CardTitle>
<CardDescription>Manage your team.</CardDescription>
</CardHeader>
<CardContent>...</CardContent>
<CardFooter>
<Button>Invite</Button>
</CardFooter>
</Card>
五个子部件各司其职:CardHeader 放标题与描述、CardContent 放主体、CardFooter 放尾部操作区。card.tsx 的注册表实现中每个子部件都带有独立的 data-slot(如 data-slot="card"),这也意味着下游可以通过 slot 选择器精细定制各区域样式——拆得越完整,可定制面越大。
Button 没有 isPending / isLoading 属性
shadcn/ui 的 Button 不提供 isPending 或 isLoading 这类加载态属性。正确做法是用 Spinner + data-icon + disabled 组合表达:
<Button disabled>
<Spinner data-icon="inline-start" />
Saving...
</Button>
这里 data-icon="inline-start" 是仓库图标规则的一部分(见 icons.md),由组件 CSS 负责图标的定位与尺寸,调用方不需要写 size-4、mr-2 之类的工具类。Spinner 本身的实现见 spinner.tsx。
TabsTrigger 必须放在 TabsList 内
永远不要把 TabsTrigger 直接渲染在 Tabs 里,必须包在 TabsList 中:
<Tabs defaultValue="account">
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="password">Password</TabsTrigger>
</TabsList>
<TabsContent value="account">...</TabsContent>
</Tabs>
TabsList 承担触发器组的样式容器职责(背景、圆角、聚焦环),tabs.tsx 中对 TabsList 与 TabsTrigger 的样式分工印证了这一点:跳过 TabsList 会让触发器失去组级样式并破坏键盘导航的视觉反馈。
Avatar 必须始终搭配 AvatarFallback
头像图片加载失败时需要兜底,因此 AvatarFallback 是必需的:
<Avatar>
<AvatarImage src="/avatar.png" alt="User" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
规则的理由很直接:没有 AvatarFallback 时,图片 404 或网络异常会导致头像位置出现空白,而 AvatarFallback(通常放姓名首字母)保证了视觉与语义的双重兜底。
用现成组件替代自定义标记
规则的最后一条是“先查组件库,再写自定义标记”的三组替代映射:
| 不要用 | 改用 |
|---|---|
<hr> 或 <div className="border-t"> |
<Separator /> |
带样式的 <div className="animate-pulse"> |
<Skeleton className="h-4 w-3/4" /> |
<span className="rounded-full bg-green-100 ..."> |
<Badge variant="secondary"> |
三者均在注册表中有对应实现:separator.tsx、skeleton.tsx、badge.tsx。使用现成组件的收益在于:它们内置了主题变量(如 text-muted-foreground、bg-primary)、data-slot 钩子与变体系统,且会随注册表更新而演进;手写的 <div className="border-t"> 则既无法跟随主题,也容易被后续维护者误当成一次性标记而改坏。
小结:组合规则的核查清单
将 composition.md 的 13 条规则收敛为一份可逐项自查的清单:
SelectItem/DropdownMenuItem/CommandItem等是否都包在对应 Group 内(对照上文映射表);- 提示条是否用了
Alert(default/destructive变体)而非自定义 div; - 空状态是否用了
Empty+EmptyHeader/EmptyContent; - Toast 是否匹配项目 base(Base UI →
toast组件,Radix/Aria → Sonner); - 浮层选型是否按场景对应 Dialog/AlertDialog/Sheet/Drawer/HoverCard/Popover;
- Dialog/Sheet/Drawer 是否都有
*Title(视觉隐藏时用sr-only); - Card 是否使用了 Header/Title/Description/Content/Footer 完整结构;
- 按钮加载态是否用
Spinner+data-icon+disabled组合,而非不存在的isPending; TabsTrigger是否包在TabsList内;Avatar是否带了AvatarFallback;- 分隔线/骨架屏/徽章是否分别用了
Separator/Skeleton/Badge。
这 13 条规则的共同点是:把结构与语义的职责交给组件的官方组合方式,而不是用通用 HTML 标记“模拟”一个组件的外观。SKILL.md 将 composition.md 与 styling.md、forms.md、chat.md 等并列为“始终强制”的 Critical Rules,配合 base-vs-radix.md(asChild 与 render 的差异),构成了 shadcn/ui 项目内组件代码的正确性基线。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00