传统组件库是「装一个 npm 包,用库作者给的 API」;shadcn/ui 反其道而行——组件代码直接复制进你的项目,你拥有全部源码、可任意改。当我们要在多个应用间复用这套组件时,需要一个「注册表(registry)」来集中分发它们。本文用 Bun 承担依赖安装、构建与注册表服务,分享一套可复制、可演进的组件库工程化方案。
示例工程:一个 shadcn 自定义注册表,Next.js + Storybook 文档,部署到 GitHub Pages。下面的代码片段均取自该工程真实源码。
一、为什么是 shadcn + 注册表
shadcn/ui 的核心不是「库」,而是「源码归属」:
- 组件进入你的
components/ui后,就是你自己的代码,不再受上游版本约束; - 但团队内多项目共享同一套组件时,需要一处可信源来「复制」——这就是注册表;
- shadcn CLI 通过
registry.json清单把组件打包成标准 JSON,用户一条命令即可拉取到本地。
Bun 在这里负责三件事:作为包管理器(packageManager: bun@1.3.5)、跑 Next.js 构建、跑 shadcn build 生成注册表产物。
二、初始化:components.json
注册表的入口是 components.json,它告诉 CLI 用什么风格、是否 RSC、CSS 变量放哪、路径别名如何映射:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
两点值得注意:tailwind.config 为空字符串,说明用的是 Tailwind v4 的 CSS-first 配置(样式变量直接写在 app/globals.css 里,不再需要 JS 配置文件);cssVariables: true 表示主题色走 CSS 变量,天然支持暗色模式。
三、注册表清单:registry.json
registry.json 描述「这个注册表提供哪些组件」。每个 item 有 name、type、title、description,以及 files[](指向真实源码路径)。下面是该工程真实的几个条目:
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "erishen",
"homepage": "https://erishen.github.io/shadcn-registry/",
"items": [
{
"name": "hello-world",
"type": "registry:component",
"title": "Hello World",
"description": "A simple hello world component",
"registryDependencies": ["button"],
"files": [
{
"path": "registry/new-york/blocks/hello-world/hello-world.tsx",
"type": "registry:component"
}
]
},
{
"name": "example-form",
"type": "registry:component",
"title": "Example Form",
"description": "A contact form with Zod validation.",
"dependencies": ["zod"],
"registryDependencies": ["button", "input", "label", "textarea", "card"],
"files": [
{
"path": "registry/new-york/blocks/example-form/example-form.tsx",
"type": "registry:component"
}
]
},
{
"name": "complex-component",
"type": "registry:component",
"title": "Complex Component",
"description": "A complex component showing hooks, libs and components.",
"registryDependencies": ["card"],
"files": [
{ "path": "registry/new-york/blocks/complex-component/page.tsx", "type": "registry:page", "target": "app/pokemon/page.tsx" },
{ "path": "registry/new-york/blocks/complex-component/components/pokemon-card.tsx", "type": "registry:component" },
{ "path": "registry/new-york/blocks/complex-component/components/pokemon-image.tsx", "type": "registry:component" },
{ "path": "registry/new-york/blocks/complex-component/lib/pokemon.ts", "type": "registry:lib" },
{ "path": "registry/new-york/blocks/complex-component/hooks/use-pokemon.ts", "type": "registry:hook" }
]
}
]
}
一个 item 可以只包含一个文件(如 hello-world),也可以像 complex-component 那样拉起一整个多文件功能块:它一次带来 page、两个 component、一个 lib 和一个 hook,并通过 target 指定页面落到 app/pokemon/page.tsx。registryDependencies 则会自动把所依赖的基础 UI 一并拉取。
四、真实的 UI 原语:Button 与 Card
组件本体就是标准 shadcn 源码。以 registry/new-york/ui/button.tsx 为例,用 class-variance-authority(cva)表达变体:
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 outline-none focus-visible:ring-[3px] aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)
function Button({
className, variant, size, asChild = false, ...props
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
}
export { Button, buttonVariants }
Card 则是组合式结构(registry/new-york/ui/card.tsx),拆成 CardHeader / CardTitle / CardDescription / CardContent / CardFooter / CardAction 等子组件,各自带 data-slot 便于外层样式定位:
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className)}
{...props}
/>
)
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }
注意这些组件里大量出现
bg-primary、text-card-foreground这类语义化色值——它们不是写死的颜色,而是下面globals.css里定义的 CSS 变量。
五、多文件块的真实形态
complex-component 的页面是一个 RSC(React Server Component),用 React 的 cache() 做请求去重:
import { cache } from "react"
import { PokemonCard } from "@/registry/new-york/blocks/complex-component/components/pokemon-card"
import { getPokemonList } from "@/registry/new-york/blocks/complex-component/lib/pokemon"
const getCachedPokemonList = cache(getPokemonList)
export default async function Page() {
const pokemons = await getCachedPokemonList({ limit: 12 })
if (!pokemons) return null
return (
<div className="mx-auto w-full max-w-2xl px-4">
<div className="grid grid-cols-2 gap-4 py-10 sm:grid-cols-3 md:grid-cols-4">
{pokemons.results.map((p) => (
<PokemonCard key={p.name} name={p.name} />
))}
</div>
</div>
)
}
这一条 item 之所以能「一次装好一整个功能」,正是因为 registry.json 里把 page / component / lib / hook 四类文件都列进了 files,shadcn CLI 会按 target 和类型把它们落到正确位置。
六、主题:Tailwind v4 的 CSS 变量
由于 components.json 里 tailwind.config 为空,主题色全部声明在 app/globals.css,用 Tailwind v4 的 @theme inline + oklch() 色彩空间:
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-ring: var(--ring);
--radius-lg: var(--radius);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
}
oklch() 提供感知均匀的色彩过渡,暗色模式只需在 .dark 下重定义同一组变量——按钮里写的 bg-primary 会自动跟着换肤,无需任何条件类名。
七、构建注册表产物
注册表 JSON 由官方 CLI 本地生成(shadcn build 读取 registry.json,把每个 item 的文件内联打包成 public/r/<name>.json),产物提交进仓库,再由 GitHub Pages 提供下载:
# 本地生成 public/r/*.json(标准 shadcn build 输出,schema: registry-item.json)
pnpm dlx shadcn@latest build
# 或
npx shadcn@latest build
package.json 里所有脚本都跑在 Bun 上,并声明了 Bun 作为包管理器:
{
"packageManager": "bun@1.3.5",
"scripts": {
"dev": "bun next dev",
"prod": "bun next build",
"start": "bun next start",
"lint": "bun next lint",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build -o docs",
"deploy": "bun next build && touch out/.nojekyll"
}
}
CI(.github/workflows/deploy.yml)的部署步骤也很直白:
- run: bun install
- run: bun run prod # = bun next build (静态导出到 out/)
- run: bun run build-storybook # 生成组件文档到 docs/
- run: mkdir -p out/storybook && cp -r docs/* out/storybook/
- run: touch out/.nojekyll
八、消费组件:一条命令拉取
用户不再 npm install,而是用 shadcn CLI 直接从注册表「复制」组件到自己的工程:
# 拉取 example-form(含 Zod 依赖,自动带上 button/input/label/textarea/card)
pnpm dlx shadcn@latest add https://erishen.github.io/shadcn-registry/r/example-form.json
# 拉取 hello-world
pnpm dlx shadcn@latest add https://erishen.github.io/shadcn-registry/r/hello-world.json
app/page.tsx 首页就直接把这些安装命令展示出来,并按下「Tailwind / SCSS / styled-components / 进阶」分类分组,每个组件配实时预览(ComponentPreview)与 Storybook 链接。
九、文档与多风格变体
工程用 Storybook 做组件文档(.storybook/ + stories/,bun run storybook 本地起 6006 端口,bun run build-storybook 产出 docs/)。同时 registry/ 下并排维护了四种样式路线,全部登记进同一个 registry.json:
registry/new-york/:主样式源(Tailwind + new-york 风格,ui/原语 +blocks/功能块);registry/demo/:源自 Storybook 的纯 CSS 演示组件(demo-with-button/header/page);registry/styled-components/:CSS-in-JS 路线(依赖styled-components);registry/scss-components/:SCSS Module 路线(.module.scss)。
这四种路线同一份清单下发,使用者按项目技术栈自行选择。
十、小结
shadcn + Bun 的组合把「组件库」重新定义为一份可复制的源码分发:
components.json定义风格与别名,registry.json定义分发清单——两者都是纯声明;- UI 原语(Button/Card)用 cva 表达变体,主题走 Tailwind v4 CSS 变量,暗色模式零成本;
- 多文件功能块(page + component + lib + hook)一次装好,靠
files/target落位; shadcn build生成public/r/*.json,Bun 负责依赖与构建,GitHub Pages 解决分发;- 使用者一条
shadcn add命令即可把源码「复制进自己项目」,从此完全拥有、自由改造。
如果你也在维护多应用共享的 UI,不妨试试把组件收进一个 shadcn 注册表——「复制即拥有」带来的可控性,往往比「装包即用」更经得起长期演进。
十一、源码导航
完整工程在 github.com/erishen/shadcn-registry。关键文件:
| 文件 | 作用 |
|---|---|
| components.json | shadcn CLI 配置:风格(new-york)、RSC、tailwind/alias 与 registry URL |
| registry.json | 注册表分发清单:hello-world / example-form / complex-component 等条目 |
| registry/new-york/ui/button.tsx | Button 原语,class-variance-authority 表达变体 |
| registry/new-york/ui/card.tsx | Card 原语,组合式子组件 |
| registry/demo/Button/Button.tsx | 演示组件(源自 Storybook) |
| registry/new-york/blocks/complex-component/page.tsx | 多文件功能块示例(page + component + lib + hook) |
| app/globals.css | Tailwind v4 主题:oklch 变量 + 暗色模式 |
| app/page.tsx | 演示站首页,按分类展示安装命令与实时预览 |
十二、常见问题
shadcn 注册表和普通 npm 组件库有什么本质区别?
普通组件库发到 npm 的是编译产物,你装的是”黑盒”,要改就得 fork 或覆盖样式;shadcn 注册表分发的是源码——shadcn add 把 .tsx 直接复制进你的工程,从此你完全拥有、可自由改造,不再被版本升级绑架。
为什么用 Bun 而不是 npm / pnpm?
这个注册表工程本身用 Bun 承担依赖管理、Next.js 开发与 shadcn build 构建:安装与启动更快,单文件二进制也省去 Node 版本管理。但它只决定”注册表怎么跑”,使用者消费组件时仍用各自的包管理器,互不干扰。
一条 shadcn add 怎么把多个文件装进我的项目?
注册表条目用 files 字段声明要分发的文件清单,每个文件用 target 指定落位路径(如 components/ui/button.tsx)。shadcn add 拉取对应 public/r/*.json 后,按 files/target 把文件写到你工程里对应位置,所以多文件功能块(page + component + lib + hook)能一次装好。
主题和暗色模式是怎么做的?
主题走 Tailwind v4 的 CSS-first 配置,颜色以 oklch 变量定义在 app/globals.css 的 :root 与 .dark 下(如 --background、--primary)。组件只用语义化变量,切暗色只需给根节点加 dark 类,零额外代码。
registry 里并排的四套样式(new-york / demo / styled-components / scss)怎么选?
它们都登记进同一个 registry.json,按需取用:new-york 是主样式(Tailwind + new-york);demo 是源自 Storybook 的纯 CSS 演示;styled-components 走 CSS-in-JS;scss-components 走 SCSS Module。按你项目已有的技术栈选一套即可,互不影响。
发表回复