跳到主要内容

shadcn/ui 的出现与意义

背景:复制源码,而非安装依赖

  • 模式:shadcn/ui 不发布 npm 包,而是提供可复制的源码和 CLI,生成组件文件后由团队自行维护、改造。
  • 影响:避免被包版本锁定,组件完全归团队所有;更新节奏、Breaking 变更都变成「你自己合并」,风险可控。
  • 底座:Radix UI(无样式、可访问性交互) + Tailwind(视觉)+ tailwind-merge(冲突解析)+ cva/variants(变体),形成清晰的组合范式。

核心范式

  1. 设计 tokens:通过 Tailwind 主题或 CSS 变量定义颜色、半径、间距。
  2. 变体声明:用 cvatailwind-variants 管理 size/tone/state 等。
  3. merge 兜底:封装 cn = twMerge(clsx(...)),所有组件 className 都经由 merge。
  4. 可复制:CLI 产出 Button.tsx 等源码,直接落在仓库,便于二次定制和代码审查。

与其他 Headless UI 的结合

  • Radix 不是唯一选项,同样模式可套到 Headless UI、Ark UI、Ariakit、React Aria/Headless、TanStack 状态机等无样式基座上。
  • 关键在于:交互/可访问性由基座提供,视觉与变体由 Tailwind + tailwind-merge + cva/variants 接管。换基座时,仅需调整属性映射与状态类名。
  • 示例:将 Ark UI 的 Popover 包装为 cn + variants,或用 Ariakit 的 Dialog 组件配合自定义 tokens,依然保持「复制源码、可改造、可覆盖」的模式。

什么是 Headless UI?常见基座与定位

  • 定义:只提供交互逻辑与可访问性,不附带样式(或极少样式)的组件/Hook;通过 render props、slots 或原生元素组合让你自行决定 DOM 结构与 class。
  • 典型代表:
    • Radix UI:无样式交互原件,搭配任意样式层。
    • Headless UI(Tailwind Labs):提供 render props/slots 的交互组件。
    • Ark UI / Kobalte / Ariakit:无样式 + 可访问性,适合自定义主题。
    • React Aria / React Stately:Hook 形式提供交互与状态。
    • TanStack Table:表格/数据表逻辑完全 headless,可与任意样式库组合。
    • TanStack Form(原 React Form):表单状态/校验 Hook,不负责视觉。
  • 不算严格 headless 的场景:带默认主题/样式的 UI kits(如部分 reka UI 套件)通常已经封装视觉层,虽可定制但不属于纯 headless 范畴。

Headless UI 库划分(速查)

  • 交互原件型(组件 primitives):Radix UI、Headless UI、Ark UI、Ariakit、Kobalte —— 提供 DOM 结构/状态机/可访问性,样式全由你决定。
  • Hook/逻辑型:React Aria + React Stately(ARIA + 状态)、TanStack Form(表单状态),只暴露 Hook,不负责 DOM。
  • 数据逻辑型:TanStack Table(表格/数据表逻辑,渲染自定义),近似纯逻辑层。
  • 半 Headless/带样式的 kits:如部分 reka UI 或带主题的 UI 套件,提供默认皮肤但可替换,严格意义上不算纯 headless。

组件示例(缩写版)

// utils/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(...inputs))

// components/button.tsx
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/utils/cn'

const button = cva(
'inline-flex items-center gap-2 rounded-md font-medium transition-colors',
{
variants: {
tone: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
ghost: 'bg-transparent text-foreground hover:bg-accent',
},
size: { sm: 'h-8 px-2.5 text-sm', md: 'h-10 px-3 text-sm', lg: 'h-12 px-4 text-base' },
},
defaultVariants: { tone: 'default', size: 'md' },
}
)

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof button>

export function Button({ tone, size, className, ...props }: ButtonProps) {
return <button className={cn(button({ tone, size }), className)} {...props} />
}
  • 通过 cva 统一管理 tone/sizecn 确保用户传入的类(如 px-10bg-yellow-400)能可预测地覆盖默认值。
  • 组件文件在项目内,可随团队规范调整(如换色板、改 radius、增加 slots),而不是等待上游发版。

行业意义

  • 模板化脚手架:为 Tailwind 场景提供「可复制源码 + merge + variants」的即插架构,成为大量团队的默认起点。
  • 所有权转移:将组件所有权从包作者转回使用团队,降低依赖风险,方便安全审计与定制化。
  • 事实标准:使 tailwind-merge 成为类名冲突解决的默认方案,催生各类 cn 封装与自定义 merge 规则。
  • 无样式交互的普及:推广了「Radix 等无样式交互 + 原子化样式」的组合,证明无需高度封装也能快速产出高可用组件。

想要深挖「merge 解决什么问题」?见「tailwind-merge、cva、tailwind-variants 精要」章节。