1. 概述
shadcn/ui 不是一个装完就直接用的完整组件库,它更像一套「可复制、可改造、可放进自己代码库」的组件源码方案。
它的价值不在于帮你藏住实现细节,而在于把实现权交还给你:样式是 Tailwind,交互底座是 Radix,组件源码本身就在项目里,后面要改结构、改 class、改交互都可以直接改。
放到这个 monorepo 里,它特别合适。
因为我们已经有了 Tailwind v4 + @repo/ui 这条共享组件链路,这时候再引入 shadcn/ui,目标就不该是让 web 和 admin 各自再生成一份按钮和表单组件,而是把首批基础组件直接沉淀到 packages/ui,让两个子站一起复用。
这样做有三个好处:
- 组件 API 统一,两个子站不会各写各的
- 共享包继续掌握源码,后面要改样式和交互不用绕远路
- 可以基于共享包的 tailwindcss 顺手验证
cva、cn、Radix 基础依赖
因此,我们先按下面这个目标推进:
1在现有 Tailwind v4 + @repo/ui 共享包方案之上,引入第一批 shadcn/ui 基础组件,并在 apps/web 与 apps/admin 中实际使用这些组件完成验证。23本次首批组件:4- Button5- Card6- Input7- Label8- Separator
2. 接入边界
这里继续把 packages/ui 当成唯一组件来源,不在 apps/web 和 apps/admin 各自生成一套 shadcn 组件,确保两个子站使用的是同一个组件库。
也就是说,应用侧继续保持这种导入方式:
1import { Button } from '@repo/ui/button'2import { Card } from '@repo/ui/card'
这条规则有两个直接作用。
一是避免组件源码散落到多个子站,后面改一个按钮要追三份代码。二是保持共享包出口稳定,先用最小改动把首批组件接起来,暂时不额外引入更深的目录分层和导出规则。
继续沿用 packages/ui/src/theme.css 里已有的 token、@theme 和 Tailwind 扫描方式,先把基础组件接入、API 统一和跨应用验证做完。
3. 补充 workspace 依赖
shadcn 风格组件真正依赖的不多,核心依赖就是三类东西:
class-variance-authority负责变体管理clsx+tailwind-merge负责类名合并- Radix 的轻量 primitives 提供
Slot、Label、Separator这些底座
因为这个仓库已经在根目录用 catalog 管理共享版本,所以这里继续保持一致,把公共依赖收进 pnpm-workspace.yaml:
1catalog:2class-variance-authority: ^0.7.13clsx: ^2.1.14tailwind-merge: ^3.3.15"@radix-ui/react-slot": ^1.2.36"@radix-ui/react-label": ^2.1.77"@radix-ui/react-separator": ^1.1.7
然后在 packages/ui/package.json 里把这些依赖放进 dependencies:
01{02"dependencies": {03"@radix-ui/react-label": "catalog:",04"@radix-ui/react-separator": "catalog:",05"@radix-ui/react-slot": "catalog:",06"class-variance-authority": "catalog:",07"clsx": "catalog:",08"react": "catalog:",09"react-dom": "catalog:",10"tailwind-merge": "catalog:"11}12}
4. 在共享包里补 cn 工具
shadcn 风格组件几乎都会复用一个 cn 工具,所以先在共享包里把它补上。
文件位置放在 packages/ui/src/lib/utils.ts:
1import { type ClassValue, clsx } from 'clsx'2import { twMerge } from 'tailwind-merge'34export function cn(...inputs: ClassValue[]) {5return twMerge(clsx(inputs))6}
这里有两个细节。
第一,组件内部统一走相对路径导入。
这一步先不对外暴露 @repo/ui/lib/*,避免还没稳定就把内部工具路径变成公共 API。
第二,clsx 和 tailwind-merge 要一起用。
单独用 clsx 只能解决条件拼接,处理不了 Tailwind 冲突类合并;加上 twMerge 之后,像 px-4 px-6、rounded-xl rounded-2xl 这类冲突才能按预期收敛。
5. 先升级 Button 和 Card
优先复用共享包里已有文件,直接原位升级。
5.1 Button
packages/ui/src/button.tsx 之前只是一个非常简单的演示按钮,这一轮把它改成 shadcn 风格的 Button。
核心点有三个:
- 用
cva定义variant和size - 用
Slot支持asChild - 保持导出路径不变,应用侧导入方式不用改
01import * as React from 'react'02import { Slot } from '@radix-ui/react-slot'03import { cva, type VariantProps } from 'class-variance-authority'04import { cn } from './lib/utils'0506const buttonVariants = cva(07'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950',08{09variants: {10variant: {11default: 'bg-brand-500 text-white hover:bg-brand-600',12secondary: 'bg-white/10 text-white hover:bg-white/15',13outline: 'border border-white/15 bg-transparent text-slate-100 hover:bg-white/10',14ghost: 'text-slate-100 hover:bg-white/10',15},16size: {17default: 'h-11 px-5',18sm: 'h-9 px-4 text-xs',19lg: 'h-12 px-6 text-base',20icon: 'size-10 rounded-full',21},22},23defaultVariants: {24variant: 'default',25size: 'default',26},27}28)2930type ButtonProps = React.ComponentProps<'button'> &31VariantProps<typeof buttonVariants> & {32asChild?: boolean33}3435function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {36const Comp = asChild ? Slot : 'button'3738return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />39}4041export { Button, buttonVariants }
这里的 asChild 很关键。
后面如果要让按钮直接包住 a、Link 或别的组件,就不用额外再写一层 LinkButton。在共享组件里先把这个能力补齐,后面会很省事。
5.2 Card
Card 也不再保留原来那种单文件、单组件、强绑定 title 和 href 的结构,而是改成一组更标准的组合式子组件:
CardCardHeaderCardTitleCardDescriptionCardContentCardFooter
01import * as React from 'react'02import { cn } from './lib/utils'0304function Card({ className, ...props }: React.ComponentProps<'div'>) {05return (06<div07className={cn(08'rounded-[2rem] border border-white/10 bg-slate-950/45 text-slate-50 shadow-card backdrop-blur',09className10)}11{...props}12/>13)14}1516function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {17return <div className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />18}1920function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {21return <div className={cn('text-xl font-semibold tracking-tight', className)} {...props} />22}2324function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {25return <div className={cn('text-sm leading-6 text-slate-300', className)} {...props} />26}2728function CardContent({ className, ...props }: React.ComponentProps<'div'>) {29return <div className={cn('px-6 pb-6', className)} {...props} />30}3132function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {33return <div className={cn('flex items-center px-6 pb-6', className)} {...props} />34}3536export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
这种结构更适合共享包。
因为它不再替你预设内容结构,应用侧可以自由拼装:表单卡片、信息卡片、操作卡片都能沿用同一套基础组件。
6. 再补 Input、Label、Separator
首批组件里剩下三个都比较直接,但验证意义很强。
Input验证表单类组件的共享样式Label验证 Radix primitive 与语义绑定Separator验证基础结构组件和横向视觉分隔
01import * as React from 'react'02import { cn } from './lib/utils'0304function Input({ className, type, ...props }: React.ComponentProps<'input'>) {05return (06<input07type={type}08data-slot="input"09className={cn(10'flex h-11 w-full rounded-2xl border border-white/12 bg-white/5 px-4 py-2 text-sm text-slate-50 outline-none transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-brand-500/60 focus-visible:ring-2 focus-visible:ring-brand-500/40',11className12)}13{...props}14/>15)16}1718export { Input }
01import * as React from 'react'02import * as LabelPrimitive from '@radix-ui/react-label'03import { cn } from './lib/utils'0405function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {06return (07<LabelPrimitive.Root08className={cn('text-sm font-medium leading-none text-slate-200', className)}09{...props}10/>11)12}1314export { Label }
01import * as React from 'react'02import * as SeparatorPrimitive from '@radix-ui/react-separator'03import { cn } from './lib/utils'0405function Separator({06className,07orientation = 'horizontal',08decorative = true,09...props10}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {11return (12<SeparatorPrimitive.Root13decorative={decorative}14orientation={orientation}15className={cn(16'shrink-0 bg-white/10',17orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',18className19)}20{...props}21/>22)23}2425export { Separator }
这里最值得关注的是 Label 的验证价值。
只要页面里 htmlFor 和 id 对得上,点 Label 能聚焦对应的 Input,就说明共享组件除了样式正常,基础语义和交互关系也没丢。
7. 复用共享演示入口做第一轮验证
为了验证两个子站是否能够正确消费这批新组件,最省事的方式就是直接修改现有的 packages/ui/src/tailwind-demo.tsx
因为 web 和 admin 已经在首页接了这个演示组件,所以一旦这里改成新组件,两个子站就会自动开始帮我们验证共享包编译、Tailwind 扫描和导入导出是否都正常。
01import { Button } from './button'02import {03Card,04CardContent,05CardDescription,06CardFooter,07CardHeader,08CardTitle,09} from './card'10import { Input } from './input'11import { Label } from './label'12import { Separator } from './separator'1314type TailwindDemoProps = {15appName: string16}1718const features = [19'Shared Tailwind utilities',20'Shadcn-style primitives',21'Rendered from @repo/ui',22]2324export function TailwindDemo({ appName }: TailwindDemoProps) {25return (26<Card className="w-full max-w-5xl overflow-hidden bg-[linear-gradient(135deg,rgba(79,124,255,0.18),rgba(15,23,42,0.92))]">27<CardHeader className="gap-4 md:flex-row md:items-start md:justify-between">28<div className="space-y-3">29<span className="inline-flex w-fit items-center rounded-full border border-brand-500/20 bg-brand-50 px-3 py-1 text-xs font-semibold tracking-[0.24em] text-brand-700 uppercase">30shared ui package31</span>32<div className="space-y-2">33<CardTitle className="text-2xl text-white md:text-3xl">34Tailwind and shadcn primitives are active in {appName}35</CardTitle>36<CardDescription className="max-w-2xl text-slate-200">37This section is rendered from <code className="rounded bg-white/10 px-2 py-1 text-slate-100">packages/ui</code> and now uses reusable Button, Card, Input, Label, and Separator components.38</CardDescription>39</div>40</div>4142<div className="grid gap-3 sm:grid-cols-3">43{features.map((feature) => (44<div45key={feature}46className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm font-medium text-slate-100 shadow-sm shadow-black/10"47>48{feature}49</div>50))}51</div>52</CardHeader>5354<CardContent className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">55<div className="space-y-4 rounded-[1.5rem] border border-white/10 bg-slate-950/35 p-5">56<div className="space-y-2">57<Label htmlFor={`${appName}-workspace`}>Workspace label</Label>58<Input id={`${appName}-workspace`} defaultValue={`${appName}.workspace.local`} />59</div>60<Separator />61<div className="flex flex-wrap gap-3">62<Button>Primary action</Button>63<Button variant="secondary">Secondary action</Button>64<Button variant="outline">Outline action</Button>65</div>66</div>6768<div className="rounded-[1.5rem] border border-white/10 bg-white/5 p-5 text-sm leading-6 text-slate-200">69<p>70The same primitives can now be imported from the shared package and reused in both sub-apps without adding separate component copies.71</p>72</div>73</CardContent>7475<CardFooter className="flex-wrap gap-3 border-t border-white/10 pt-6">76<Button asChild variant="ghost">77<a href="https://ui.shadcn.com/docs/components/button" target="_blank" rel="noopener noreferrer">78View shadcn button reference79</a>80</Button>81<Button asChild variant="outline">82<a href="https://ui.shadcn.com/docs/components/card" target="_blank" rel="noopener noreferrer">83View card reference84</a>85</Button>86</CardFooter>87</Card>88)89}
这一步能顺带验证三件事:
- 新增组件文件确实被
packages/ui/src/theme.css扫描到了 @repo/ui/*顶层子路径导入方式仍然成立- 共享包里的组件样式和结构在两个子站都能正确落地
8. 两个子站都要写一段更贴近业务的用法
只有共享演示块还不够。
因为它本质上还是一个通用展示区,没法完全说明组件进入真实页面结构后是否依旧正常。
所以这里分别在 apps/web/app/page.tsx 和 apps/admin/app/page.tsx 再加一段更贴近业务的基础用法。
8.1 web
web 首页可以放一个简洁表单区块,重点验证 Label + Input + Button + Card + Separator 这一套组合:
01import { Button } from '@repo/ui/button'02import {03Card,04CardContent,05CardDescription,06CardHeader,07CardTitle,08} from '@repo/ui/card'09import { Input } from '@repo/ui/input'10import { Label } from '@repo/ui/label'11import { Separator } from '@repo/ui/separator'12import { TailwindDemo } from '@repo/ui/tailwind-demo'1314export default function Home() {15return (16<>17<TailwindDemo appName="web" />1819<Card>20<CardHeader>21<CardTitle>Primitive validation in web</CardTitle>22<CardDescription>23This section imports shared Button, Input, Label, Card, and Separator components directly from <code className="rounded bg-white/10 px-2 py-1 text-slate-100">@repo/ui</code>.24</CardDescription>25</CardHeader>26<CardContent className="space-y-5">27<div className="grid gap-2">28<Label htmlFor="project-name">Project name</Label>29<Input id="project-name" placeholder="AI Agent workspace" />30</div>31<Separator />32<div className="flex flex-wrap gap-3">33<Button>Save draft</Button>34<Button variant="secondary">Preview</Button>35<Button variant="outline">Open docs</Button>36</div>37</CardContent>38</Card>39</>40)41}
这段的价值在于它更接近真实业务页面:有表单项、有分隔、有操作按钮,而且所有组件都直接从共享包导入。
8.2 admin
admin 首页则更适合做一个操作区,重点验证不同按钮尺寸和变体:
01import { Button } from '@repo/ui/button'02import {03Card,04CardContent,05CardDescription,06CardHeader,07CardTitle,08} from '@repo/ui/card'09import { Input } from '@repo/ui/input'10import { Label } from '@repo/ui/label'11import { Separator } from '@repo/ui/separator'12import { TailwindDemo } from '@repo/ui/tailwind-demo'1314export default function Home() {15return (16<>17<TailwindDemo appName="admin" />1819<Card>20<CardHeader>21<CardTitle>Admin primitive validation</CardTitle>22<CardDescription>23This block checks the same shared primitives in a second app with a slightly different layout and button sizing.24</CardDescription>25</CardHeader>26<CardContent className="space-y-5">27<div className="grid gap-2 md:max-w-md">28<Label htmlFor="tenant-id">Tenant identifier</Label>29<Input id="tenant-id" placeholder="team-enterprise-01" />30</div>31<Separator />32<div className="flex flex-wrap gap-3">33<Button size="sm">Queue sync</Button>34<Button variant="secondary">Inspect</Button>35<Button size="lg" variant="outline">Publish changes</Button>36</div>37</CardContent>38</Card>39</>40)41}
两个页面分开这样验证之后,能更明确地确认一件事:共享包里的基础组件不是只在一个演示块里可用,而是真的能跨应用消费。
9. 总结
这次不急着上 Dialog、Popover、DropdownMenu 这类更重的交互组件,原因很现实。
因为一旦开始做这类组件,主题变量、客户端边界、Portal、焦点管理、可访问性细节都会一起进来,文章复杂度会立刻上升。
当前阶段先把下面这些基础能力跑通,更值:
- 按钮变体和尺寸
- 表单输入与标签绑定
- 结构容器和分隔线
cva、cn、Radix primitives 的接入链路- 共享包被多个应用消费时的编译与样式稳定性
这批组件已经足够覆盖「共享包接入 shadcn 风格组件」最核心的路径。
处理完之后,我们可以按照下面的命令进行验证:
1pnpm install2pnpm --filter @repo/ui check-types3pnpm --filter web check-types4pnpm --filter admin check-types5pnpm lint6pnpm build
然后分别启动两个前端:
1pnpm --filter web dev2pnpm --filter admin dev
浏览器里打开:
http://localhost:3005http://localhost:3006
检查:
- 两个子站都能看到新的共享演示区和页面内业务区块
Button的不同variant和size样式确实有差异- 点击
Label可以聚焦对应Input Separator可见,Card结构正常- 控制台没有 hydration、module resolution、Tailwind class 丢失这类报错
如果出问题,优先排查下面几项。
共享组件样式缺失
先看新增组件文件是不是都位于 packages/ui/src 下面,因为 theme.css 的 @source "./**/*.{ts,tsx}"; 只会扫描这个范围。
Button 的 asChild 类型或导入报错
优先看 @radix-ui/react-slot 有没有装上,以及 Button 的 props 类型是不是包含 asChild?: boolean。
应用侧导入失败
先核对顶层文件名和共享包导出规则是不是对应得上,重点看 @repo/ui/button、@repo/ui/card 这类现有子路径有没有被破坏。