shadcn 是 Web 出海圈最常见的起步方案了,各种 AI 工具即便不用 NextJS,也会选择 shadcn + Tailwind。独立开发社区,似乎看不到有人在讨论 Material UI,AntD。甚至 Tailwind 最佳排档 Daisy UI 聊的人也少了。
之前百花齐放、各有千秋的组件库,现在收敛到一个默认选择。shadcn 是怎么做到的呢?
想要在一个群体中出类拔萃,得有独树一帜并且行之有效的哲学。UI 组件库的问题就是同质化严重,只靠更多的样式和组件,很难建立起长期优势。
这时候就需要挖一下底层。之前的各种 React 组件设计系统,本质上都是设计一套工作方式和审美,我们来使用。但很快或者因为业务需求,或者单纯审美疲劳,人们就想要加点东西。然后就发现,这事不简单!
而 shadcn 的底层假设是“用户一定不想重复造轮子;用户也总有一天想要改组件”。所以设计哲学从一开始就是“把代码设计得很容易改”。
shadcn 做了以下几个事:
- 1. 代码交给你。有专门工具负责导入组件代码;导入进来这几小段代码和 shadcn 没关系,不会因为哪一天 shadcn 升级就用不了了(代码里根本没有
import X from shadcn这种东西) - 2. 使用科学和现代的方式设计层级:基本的 ARIA 行为模式(选择、点击、聚焦等等)交给 Radix/Base UI 这样的 headless “原语”组件(primitives),视觉样式交给 tailwind,组件变体通过 CVA(Class Variance Authority) 方式来定义(而不是通过 if-else 的方式写在 JS 的逻辑里)。
在这个分层设计下,我们要改一个组件——
- 1. 绝大多数情况下,只需要在引用 shadcn 组件的时候,传入不同的 className 就可以改变外观(颜色、间距、圆角大小、动画效果)
- 2. 如果要修改或者增加属性,在 variants 里增删 key(比如 buttonVariants)
- 3. 结构调整(修改组件的 DOM 结构),改组件的 JSX 代码
- 4. 键盘行为(修改组件的键盘交互逻辑),改对应的 Base/Radix 组件属性(这些也都包含在 shadcn 组件代码里)
具体细节——
- 1. 通过 Tailwind 控制外观,是什么意思?
组件的所有外观都能通过传入Tailwind className 来覆盖,比如想要一个圆形按钮,不管按钮默认是个什么样,只需要写:
<Button className="rounded-full" />
出来就一定是个圆的。
- 2. 通过 CVA 实现 API 的自定义,是什么意思?
传统的 UI 组件库这样定义按钮:
<Button type="primary" size="large" danger ghost />
想要增加一个可以全局使用的按钮属性,最快的方法是基于这个 Button 重新封装一个新的,然后在页面到处使用这个<NewButton>。
但 shadcn 的组件定义是透明的(这里是一个简化的 button)——
// components/ui/button.tsximport { cva } from "class-variance-authority"const buttonVariants = cva( "inline-flex items-center justify-center rounded-md", { variants: { variant: { default: "bg-primary text-primary-foreground", outline: "border border-input", }, size: { default: "h-10 px-4 py-2", sm: "h-9 rounded-md px-3", }, }, defaultVariants: { variant: "default", size: "default", }, })function Button({ variant, size, className, ...props }) { return ( <button className={cn(buttonVariants({ variant, size }), className)} {...props} /> )}export {Button, buttonVariants};
想要把 variant 改成更直观的“色调”属性?只需要改两处:
// buttonVariantsvariants: { tone: { primary: "bg-blue-600 text-white hover:bg-blue-700", danger: "bg-red-600 text-white hover:bg-red-700" }, //size}// Buttonexport function Button({ tone, size, className, ...props }: ButtonProps) { return ( <button className={cn(buttonVariants({ tone, size }), className)} {...props} /> )}
之后就可以在项目的任何地方可以使用这个新的属性了:<Button tone="primary" size="sm" />
一个带结构的 shadcn 按钮可能长这样:
export function StructuredButton({ leftIcon, rightIcon, ...props }) { return ( <button> {leftIcon} <span>{props.children}</span> {rightIcon} </button> )}
因为代码就在 /components/ui 下,要改它的结构直接改就行,比如去掉leftIcon。
这个可能很少用到。
比如有一个对话框,删除某个图片时需要用户确认,这里会涉及一些 Radix/Base UI 给提供的“默认键盘行为”(ARIA 交互模式):
- • 打开时 focus 是落在第一个 focusable 元素
——我们的组件可以自然带有这些我们熟悉的行为,就是因为 shadcn 建立在 Radix 组件基础上。
现在假设弹窗的第一个按钮是“确定”,第二个是“取消”。如果这是个高危操作,而我们不希望用户手滑给确认了、还回头骂我们,一个办法是默认 focus 的元素在“取消”按钮上——
"use client"import * as React from "react"import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,} from "@/components/ui/dialog"import { Button } from "@/components/ui/button"export function DangerConfirmDialog({ open, onOpenChange }) { const cancelRef = React.useRef<HTMLButtonElement | null>(null) return ( <Dialog open={open} onOpenChange={onOpenChange}> <DialogContent onOpenAutoFocus={(e) => { e.preventDefault() cancelRef.current?.focus() }} > <DialogHeader> <DialogTitle>删除确认</DialogTitle> <DialogDescription> 是否确认? </DialogDescription> </DialogHeader> <DialogFooter> <Button ref={cancelRef} tone="primary"> 取消 </Button> <Button tone="danger"> 确认 </Button> </DialogFooter> </DialogContent> </Dialog> )}
在常规的组件库里(比如 AntD),自动聚焦这种底层逻辑是隐藏的,即便能改也需要通过特定的 API。shadcn 直接把onOpenAutoFocus 暴露出来,我们(或者 AI)改起来当然容易很多。
除了上面这些总体设计,在一些复杂的组件的底层实现上,shadcn 的选择也能体现作者很高的选型标准。比如 data table 就是以设计精良的 TanStack table 为基础实现的。
在看 shadcn 时,总能想到曾经每天用的 Python web 框架 FastAPI。它的作者也是靠对各种先进工具的灵活组合,使得 FastAPI 兼具最强性能和一流开发者体验。真是既好看又实用啊。这需要有代码能力,对工具链的理解,还有大师级的代码审美。