Coolify 仓库 shadcn 组件组合规范(composition.md)全解:Group 嵌套、覆盖层选择与"组件替代自定义标记"实践
本文以 Coolify 仓库中 .agents/skills/shadcn/rules/composition.md 为核心,完整拆解其中 14 条组件组合(Composition)规则:Item 必须嵌套在 Group 内、覆盖层组件选型、Dialog/Sheet/Drawer 的 Title 强制要求、Card 完整结构、Button 加载态的正确写法,以及 Separator/Skeleton/Badge 替代自定义标记的模式。读完你可以直接把这些规则作为 shadcn/ui 项目(或 AI Agent 辅助编码场景)的组件组合检查清单,并对照 SKILL.md 中的 Critical Rules 理解每条规则的来龙去脉。
这份文档在 Coolify 仓库中的定位
composition.md 位于 .agents/skills/shadcn/rules/ 目录,是 Coolify 仓库为 AI Agent 内置的一套 shadcn/ui 技能(skill)中的"组件结构"规则文件。从 SKILL.md 的 Critical Rules 部分可以看到,它被明确归类为两条规则的详细出处:
- Component Structure → composition.md:覆盖 Group 嵌套、asChild/render 自定义触发器、Title 强制要求、Card 完整组合、Button 无
isPending、TabsTrigger 位置、AvatarFallback 等; - Use Components, Not Custom Markup → composition.md:覆盖 Alert、Empty、sonner Toast、Separator、Skeleton、Badge 等"用现成组件而非手写标记"的规则。
该 skill 的适用前提是"任何带 components.json 的 shadcn 项目"(SKILL.md 描述中的触发条件),配套文件还有 forms.md(表单布局)、styling.md(样式与 Tailwind)、icons.md(图标规范)和 base-vs-radix.md(base 与 radix 两套底层原语的 API 差异)。composition.md 与它们的关系是:它管"组件之间怎么嵌套",而不是"样式怎么写"或"表单怎么布局"。需要说明的是,Coolify 主站 UI 本身是 Laravel + Blade + Alpine.js 技术栈(见 package.json 与 TECH_STACK.md),本规则文件属于面向 Agent 的通用 shadcn 技能包内容,因此下文的代码示例均为 shadcn/ui 生态下的 React/TSX 用法。
总览:14 条组合规则清单
文档开头给出的 Contents 完整列出了全部规则,可视为检查清单:
- Items always inside their Group component(Item 必须位于 Group 内)
- Callouts use Alert(提示框用 Alert)
- Empty states use Empty component(空状态用 Empty)
- Toast notifications use sonner(Toast 用 sonner)
- Choosing between overlay components(覆盖层组件选型)
- Dialog, Sheet, and Drawer always need a Title(覆盖层必须有 Title)
- Card structure(Card 完整结构)
- Button has no isPending or isLoading prop(Button 加载态的正确写法)
- TabsTrigger must be inside TabsList(TabsTrigger 必须在 TabsList 内)
- Avatar always needs AvatarFallback(Avatar 必须带 Fallback)
- Use Separator instead of raw hr or border divs(分隔线用 Separator)
- Use Skeleton for loading placeholders(加载占位用 Skeleton)
- Use Badge instead of custom styled spans(徽章用 Badge)
- Use existing components instead of custom markup(总原则:优先用组件而非自定义标记)
Item 必须嵌套在 Group 组件内
这是文档的第一条规则,核心要求是:永远不要把 Item 直接渲染在内容容器里。以 Select 为例,文档给出 Incorrect/Correct 对照:
// 错误:SelectItem 直接放在 SelectContent 下
<SelectContent>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectContent>
// 正确:先包一层 SelectGroup
<SelectContent>
<SelectGroup>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
</SelectGroup>
</SelectContent>
文档随后给出了一张完整的 Item → Group 映射表,说明该规则适用于所有"基于 Group 的组件":
| Item | Group |
|---|---|
SelectItem、SelectLabel |
SelectGroup |
DropdownMenuItem、DropdownMenuLabel、DropdownMenuSub |
DropdownMenuGroup |
MenubarItem |
MenubarGroup |
ContextMenuItem |
ContextMenuGroup |
CommandItem |
CommandGroup |
这条规则同样出现在 SKILL.md 的 Critical Rules 摘要中("Items always inside their Group"),并且在 SKILL.md 的 Workflow 第 7 步中还被用作审查从社区 registry 添加组件时的检查项——"Check for missing sub-components (e.g. SelectItem without SelectGroup)"。也就是说,从第三方 registry 拉下来的组件若缺失 Group 包裹,属于需要人工/Agent 修复的组合缺陷,而不是可接受的写法。
补充一层背景:为什么 Group 层如此重要?从 base-vs-radix.md 对 Select 的 API 描述可以看出,base 原语要求把数据通过 items 属性传给根组件、radix 则用内联 JSX,两种底层的 SelectContent 渲染机制都依赖 SelectGroup 来组织列表结构(包括键盘导航分组与视觉间距)。因此"漏掉 Group"不仅是不规范的代码风格问题,还可能导致组件在某个底层原语上渲染或行为异常——这也是该规则被放进"always enforced"(始终强制)类别的原因。
提示框(Callout)使用 Alert
第二条规则约定提示类内容一律用 Alert 组合,文档给出标准结构:
<Alert>
<AlertTitle>Warning</AlertTitle>
<AlertDescription>Something needs attention.</AlertDescription>
</Alert>
</parameter>
即 Alert 根组件 + AlertTitle + AlertDescription 三段式结构。SKILL.md 的对应摘要为"Callouts use Alert. Don't build custom styled divs."——禁止用自定义样式的 div 手搓提示框,因为 Alert 已内置语义化角色、间距与图标位(结合 icons.md 可知 Alert 内的图标由组件 CSS 处理尺寸,不需要 size-4 之类的类名)。
空状态使用 Empty 组件
空状态不手写,而用 Empty 组件族完整组合。文档示例展示了四个子组件的层级:Empty → EmptyHeader →(EmptyMedia + EmptyTitle + EmptyDescription)→ EmptyContent:
<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>
</parameter>
要点有两处:EmptyMedia 通过 variant="icon" 声明媒体类型为图标并直接承载图标组件;EmptyContent 是放置主操作(如"Create Project"按钮)的位置。SKILL.md 的组件选择表也明确把"Empty states"映射到 Empty 这一个组件(SKILL.md),空状态没有第二种合规写法。
Toast 通知使用 sonner
文档约定 Toast 一律来自 sonner 库的 toast() 函数,而不是 shadcn 自家的组件:
import { toast } from "sonner"
toast.success("Changes saved.")
toast.error("Something went wrong.")
toast("File deleted.", {
action: { label: "Undo", onClick: () => undoDelete() },
})
三种用法分别覆盖:成功通知(toast.success)、错误通知(toast.error)、带操作按钮的通知(第二个参数传 action 对象,label + onClick 实现"撤销删除"这类可交互 Toast)。这与 SKILL.md 组件选择表"Feedback → sonner (toast)"(SKILL.md)一致:shadcn 生态中 Toast 的职责由 sonner 承担,Alert/Badge/Progress/Skeleton/Spinner 承担其余反馈形态。
覆盖层组件选型:Dialog、Sheet、Drawer 与 HoverCard
选择哪个覆盖层组件,文档给出一张按"使用场景 → 组件"的对照表:
| 使用场景 | 组件 |
|---|---|
| 需要输入的聚焦任务 | Dialog |
| 破坏性操作确认 | AlertDialog |
| 侧边面板(详情或筛选) | Sheet |
| 移动端优先的底部面板 | Drawer |
| 悬停显示快速信息 | HoverCard |
| 点击显示小范围上下文内容 | Popover |
这张表与 SKILL.md 组件选择表中"Overlays"一行(Dialog (modal)、Sheet (side panel)、Drawer (bottom sheet)、AlertDialog (confirmation))互为印证。选型判断可归纳为三个维度:是否需要用户输入(Dialog)、是否不可逆(AlertDialog)、内容在屏幕上的空间形态(居中 / 侧边 / 底部 / 浮动跟随)。
Dialog、Sheet、Drawer 必须提供 Title
这是文档中明确的可访问性(accessibility)强制项:DialogTitle、SheetTitle、DrawerTitle 均为必需;若视觉上不需要展示标题,用 className="sr-only" 隐藏(屏幕阅读器仍可读取):
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Update your profile.</DialogDescription>
</DialogHeader>
...
</DialogContent>
结构上遵循 DialogContent → DialogHeader →(DialogTitle + DialogDescription)的层级。SKILL.md 将这条与"Use className=\"sr-only\" if visually hidden"一并列入 Critical Rules(SKILL.md),可见"缺 Title"会被视为违规而非风格问题。
Card 结构:使用完整组合,而非全部塞进 CardContent
文档要求 Card 使用"full composition",并明确反对把所有内容堆进 CardContent:
<Card>
<CardHeader>
<CardTitle>Team Members</CardTitle>
<CardDescription>Manage your team.</CardDescription>
</CardHeader>
<CardContent>...</CardContent>
<CardFooter>
<Button>Invite</Button>
</CardFooter>
</Card>
即 CardHeader(承载 CardTitle、CardDescription)、CardContent(正文)、CardFooter(放操作按钮等收尾内容)各司其职。这与 SKILL.md 的表述一致:"Use full Card composition. CardHeader/CardTitle/CardDescription/CardContent/CardFooter. Don't dump everything in CardContent."。
Button 没有 isPending / isLoading 属性
shadcn 的 Button 组件不内置加载态属性(没有 isPending 或 isLoading prop),正确做法是组合 Spinner + data-icon + disabled:
<Button disabled>
<Spinner data-icon="inline-start" />
Saving...
</Button>
这里同时体现了 icons.md 的规范:data-icon="inline-start" 声明图标在按钮内为前缀位置,且不给图标加尺寸类(组件 CSS 负责图标尺寸)。这套"Spinner 前置 + 文案 + disabled"的组合模式意味着加载态是展示层组合出来的,加载逻辑本身(请求是否结束)仍由业务代码控制。
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>
即 Tabs 根组件下分两支:TabsList(承载全部 TabsTrigger)与一个或多个 TabsContent(通过 value 与对应的 TabsTrigger 关联)。defaultValue 声明初始选中的 tab。这条规则在 SKILL.md 摘要中被表述为"TabsTrigger must be inside TabsList. Never render triggers directly in Tabs.",其重要性等级与 Group 嵌套规则相同,属于结构层面的硬性约束。
Avatar 必须包含 AvatarFallback
图片头像加载失败时必须有兜底,因此 AvatarFallback 是必备子组件:
<Avatar>
<AvatarImage src="/avatar.png" alt="User" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
AvatarFallback 通常展示用户姓氏首字母(如示例中的 "JD")。SKILL.md 对应规则为"Avatar always needs AvatarFallback. For when the image fails to load."——即使你"确定图片不会失败",该规则也不允许省略,这是以确定性兜底换取列表页、侧边栏等大量头像场景的健壮性。
用组件替代自定义标记:Separator、Skeleton、Badge
文档最后(也是第一条规则"总则")的替换对照表给出了三组高频场景的"不要 → 要"映射:
| Instead of(不要) | Use(要用) |
|---|---|
<hr> 或 <div className="border-t"> |
<Separator /> |
<div className="animate-pulse"> 加样式 div 拼的骨架 |
<Skeleton className="h-4 w-3/4" /> |
<span className="rounded-full bg-green-100 ..."> |
<Badge variant="secondary"> |
三条规则的共同逻辑是:shadcn 组件已经封装了语义、深色模式适配与交互行为,手写标记既丢失语义又会在主题切换时失配。其中 Skeleton 通过 className 控制尺寸形状(h-4 w-3/4 表示一行文本的占位),这符合 SKILL.md 中"className for layout, not styling"的原则——className 只用于布局维度(宽高、比例),不承担换色换字的样式职责;Badge 则通过 variant="secondary" 这类内置变体表达状态色,而不是写死 bg-green-100 之类的原始色值。SKILL.md 的关键模式示例也给出了一组对照(SKILL.md):<Badge variant="secondary">+20.1%</Badge> 正确,<span className="text-emerald-600">+20.1%</span> 错误。
与 base / radix 双底层的衔接
读完 composition.md 后还有一条必要的延伸:上述组件的嵌套结构与底层原语无关,但属性 API 会因 components.json 中 base 字段(radix 或 base)而不同。这一点由姊妹文件 base-vs-radix.md 专门覆盖,与 composition 规则直接交叉的几处包括:
- 触发器自定义元素:radix 用
asChild(如<DialogTrigger asChild>),base 用render(如<DialogTrigger render={<Button />}>),且 base 将render目标改为非 button 元素(<a>、<span>)时需追加nativeButton={false}; - Select:base 要求根组件传
items属性、用{ value: null }项表达占位符,而 radix 直接用<SelectValue placeholder="...">——但无论哪种底层,SelectContent内的SelectGroup包裹结构都保持不变; - ToggleGroup / Accordion:base 用
multiple布尔 + 数组型defaultValue,radix 用type="single" | "multiple"+ 字符串defaultValue; - Slider:base 单滑块接受标量
defaultValue={50},radix 永远是数组defaultValue={[50]}。
因此实际工作流是:先用 composition.md 确定骨架(谁包谁、缺什么子组件),再查 npx shadcn@latest info 输出的 base 字段决定属性写法。SKILL.md 的 Workflow(SKILL.md)把这一流程固化为:获取项目上下文 → 检查已安装组件 → search 找组件 → docs <component> 拉文档 → add 安装 → 审阅新增文件是否违反 Critical Rules(其中就包括本文件的 Group 嵌套检查)。
小结:把 composition.md 当作组件组合检查清单
回到文档本身,它的价值在于把 shadcn/ui 中"组件之间如何嵌套"这一最容易出错的层面,压缩成 14 条可逐条勾选的硬规则:
- 结构完整性:Item 在 Group 内、TabsTrigger 在 TabsList 内、覆盖层带 Title、Avatar 带 Fallback、Card 用完整五件套;
- 职责单一化:Callout 归 Alert、空状态归 Empty、Toast 归 sonner、加载占位归 Skeleton、分隔线归 Separator、状态标签归 Badge;
- 组合优先于 API:Button 加载态不是找
isPending属性,而是Spinner+data-icon+disabled的组合。
配合 forms.md(表单用 FieldGroup/Field/InputGroup)、styling.md(语义色、gap-* 间距、cn() 条件类)、icons.md(data-icon 与图标对象传递)和 base-vs-radix.md(双层 API 差异),这套 rules 目录构成了完整的 shadcn 代码审查标准,也是 SKILL.md 中"always enforced"规则的落地细则。
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