Skip to content

引入 shadcn/ui

要点

  • 这里继续把 packages/ui 当成唯一组件来源,不在 apps/web 和 apps/admin 各自生成一套 shadcn 组件
  • shadcn 风格组件真正依赖的不多,核心依赖就是三类东西
  • shadcn 风格组件几乎都会复用一个 cn 工具,所以先在共享包里把它补上
  • 优先复用共享包里已有文件,直接原位升级
  • 首批组件里剩下三个都比较直接,但验证意义很强

内容

1. 概述

shadcn/ui 不是一个装完就直接用的完整组件库,它更像一套「可复制、可改造、可放进自己代码库」的组件源码方案。

它的价值不在于帮你藏住实现细节,而在于把实现权交还给你:样式是 Tailwind,交互底座是 Radix,组件源码本身就在项目里,后面要改结构、改 class、改交互都可以直接改。

放到这个 monorepo 里,它特别合适。

因为我们已经有了 Tailwind v4 + @repo/ui 这条共享组件链路,这时候再引入 shadcn/ui,目标就不该是让 webadmin 各自再生成一份按钮和表单组件,而是把首批基础组件直接沉淀到 packages/ui,让两个子站一起复用。

这样做有三个好处:

  • 组件 API 统一,两个子站不会各写各的
  • 共享包继续掌握源码,后面要改样式和交互不用绕远路
  • 可以基于共享包的 tailwindcss 顺手验证 cvacn、Radix 基础依赖

因此,我们先按下面这个目标推进:

txt
// prompt.md
在现有 Tailwind v4 + @repo/ui 共享包方案之上,引入第一批 shadcn/ui 基础组件,并在 apps/web 与 apps/admin 中实际使用这些组件完成验证。

本次首批组件:

- Button

- Card

- Input

- Label

- Separator

2. 接入边界

这里继续把 packages/ui 当成唯一组件来源,不在 apps/webapps/admin 各自生成一套 shadcn 组件,确保两个子站使用的是同一个组件库。

也就是说,应用侧继续保持这种导入方式:

tsx
// index.tsx
import { Button } from '@repo/ui/button'

import { 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 提供 SlotLabelSeparator 这些底座

因为这个仓库已经在根目录用 catalog 管理共享版本,所以这里继续保持一致,把公共依赖收进 pnpm-workspace.yaml

yaml
// pnpm-workspace.yaml
catalog:

  class-variance-authority: ^0.7.1

  clsx: ^2.1.1

  tailwind-merge: ^3.3.1

  "@radix-ui/react-slot": ^1.2.3

  "@radix-ui/react-label": ^2.1.7

  "@radix-ui/react-separator": ^1.1.7

然后在 packages/ui/package.json 里把这些依赖放进 dependencies

json
// packages/ui/package.json
{

  "dependencies": {

    "@radix-ui/react-label": "catalog:",

    "@radix-ui/react-separator": "catalog:",

    "@radix-ui/react-slot": "catalog:",

    "class-variance-authority": "catalog:",

    "clsx": "catalog:",

    "react": "catalog:",

    "react-dom": "catalog:",

    "tailwind-merge": "catalog:"

  }

}

4. 在共享包里补 cn 工具

shadcn 风格组件几乎都会复用一个 cn 工具,所以先在共享包里把它补上。

文件位置放在 packages/ui/src/lib/utils.ts

typescript
// packages/ui/src/lib/utils.ts
import { type ClassValue, clsx } from 'clsx'

import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {

  return twMerge(clsx(inputs))

}

这里有两个细节。

第一,组件内部统一走相对路径导入。

这一步先不对外暴露 @repo/ui/lib/*,避免还没稳定就把内部工具路径变成公共 API。

第二,clsx 和 tailwind-merge 要一起用。

单独用 clsx 只能解决条件拼接,处理不了 Tailwind 冲突类合并;加上 twMerge 之后,像 px-4 px-6rounded-xl rounded-2xl 这类冲突才能按预期收敛。

5. 先升级 Button 和 Card

优先复用共享包里已有文件,直接原位升级。

5.1 Button

packages/ui/src/button.tsx 之前只是一个非常简单的演示按钮,这一轮把它改成 shadcn 风格的 Button。

核心点有三个:

  • cva 定义 variantsize
  • Slot 支持 asChild
  • 保持导出路径不变,应用侧导入方式不用改
tsx
// packages/ui/src/button.tsx
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-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',

  {

    variants: {

      variant: {

        default: 'bg-brand-500 text-white hover:bg-brand-600',

        secondary: 'bg-white/10 text-white hover:bg-white/15',

        outline: 'border border-white/15 bg-transparent text-slate-100 hover:bg-white/10',

        ghost: 'text-slate-100 hover:bg-white/10',

      },

      size: {

        default: 'h-11 px-5',

        sm: 'h-9 px-4 text-xs',

        lg: 'h-12 px-6 text-base',

        icon: 'size-10 rounded-full',

      },

    },

    defaultVariants: {

      variant: 'default',

      size: 'default',

    },

  }

)

type ButtonProps = React.ComponentProps<'button'> &

  VariantProps<typeof buttonVariants> & {

    asChild?: boolean

  }

function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {

  const Comp = asChild ? Slot : 'button'

  return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />

}

export { Button, buttonVariants }

这里的 asChild 很关键。

后面如果要让按钮直接包住 aLink 或别的组件,就不用额外再写一层 LinkButton。在共享组件里先把这个能力补齐,后面会很省事。

5.2 Card

Card 也不再保留原来那种单文件、单组件、强绑定 titlehref 的结构,而是改成一组更标准的组合式子组件:

  • Card
  • CardHeader
  • CardTitle
  • CardDescription
  • CardContent
  • CardFooter
tsx
// packages/ui/src/card.tsx
import * as React from 'react'

import { cn } from './lib/utils'

function Card({ className, ...props }: React.ComponentProps<'div'>) {

  return (

    <div

      className={cn(

        'rounded-[2rem] border border-white/10 bg-slate-950/45 text-slate-50 shadow-card backdrop-blur',

        className

      )}

      {...props}

    />

  )

}

function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {

  return <div className={cn('flex flex-col gap-1.5 p-6', className)} {...props} />

}

function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {

  return <div className={cn('text-xl font-semibold tracking-tight', className)} {...props} />

}

function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {

  return <div className={cn('text-sm leading-6 text-slate-300', className)} {...props} />

}

function CardContent({ className, ...props }: React.ComponentProps<'div'>) {

  return <div className={cn('px-6 pb-6', className)} {...props} />

}

function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {

  return <div className={cn('flex items-center px-6 pb-6', className)} {...props} />

}

export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }

这种结构更适合共享包。

因为它不再替你预设内容结构,应用侧可以自由拼装:表单卡片、信息卡片、操作卡片都能沿用同一套基础组件。

6. 再补 Input、Label、Separator

首批组件里剩下三个都比较直接,但验证意义很强。

  • Input 验证表单类组件的共享样式
  • Label 验证 Radix primitive 与语义绑定
  • Separator 验证基础结构组件和横向视觉分隔
tsx
// packages/ui/src/input.tsx
import * as React from 'react'

import { cn } from './lib/utils'

function Input({ className, type, ...props }: React.ComponentProps<'input'>) {

  return (

    <input

      type={type}

      data-slot="input"

      className={cn(

        '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',

        className

      )}

      {...props}

    />

  )

}

export { Input }
tsx
// packages/ui/src/label.tsx
import * as React from 'react'

import * as LabelPrimitive from '@radix-ui/react-label'

import { cn } from './lib/utils'

function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {

  return (

    <LabelPrimitive.Root

      className={cn('text-sm font-medium leading-none text-slate-200', className)}

      {...props}

    />

  )

}

export { Label }
tsx
// packages/ui/src/separator.tsx
import * as React from 'react'

import * as SeparatorPrimitive from '@radix-ui/react-separator'

import { cn } from './lib/utils'

function Separator({

  className,

  orientation = 'horizontal',

  decorative = true,

  ...props

}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {

  return (

    <SeparatorPrimitive.Root

      decorative={decorative}

      orientation={orientation}

      className={cn(

        'shrink-0 bg-white/10',

        orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',

        className

      )}

      {...props}

    />

  )

}

export { Separator }

这里最值得关注的是 Label 的验证价值。

只要页面里 htmlForid 对得上,点 Label 能聚焦对应的 Input,就说明共享组件除了样式正常,基础语义和交互关系也没丢。

7. 复用共享演示入口做第一轮验证

为了验证两个子站是否能够正确消费这批新组件,最省事的方式就是直接修改现有的 packages/ui/src/tailwind-demo.tsx

因为 webadmin 已经在首页接了这个演示组件,所以一旦这里改成新组件,两个子站就会自动开始帮我们验证共享包编译、Tailwind 扫描和导入导出是否都正常。

tsx
// packages/ui/src/tailwind-demo.tsx
import { Button } from './button'

import {

  Card,

  CardContent,

  CardDescription,

  CardFooter,

  CardHeader,

  CardTitle,

} from './card'

import { Input } from './input'

import { Label } from './label'

import { Separator } from './separator'

type TailwindDemoProps = {

  appName: string

}

const features = [

  'Shared Tailwind utilities',

  'Shadcn-style primitives',

  'Rendered from @repo/ui',

]

export function TailwindDemo({ appName }: TailwindDemoProps) {

  return (

    <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))]">

      <CardHeader className="gap-4 md:flex-row md:items-start md:justify-between">

        <div className="space-y-3">

          <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">

            shared ui package

          </span>

          <div className="space-y-2">

            <CardTitle className="text-2xl text-white md:text-3xl">

              Tailwind and shadcn primitives are active in {appName}

            </CardTitle>

            <CardDescription className="max-w-2xl text-slate-200">

              This 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.

            </CardDescription>

          </div>

        </div>

        <div className="grid gap-3 sm:grid-cols-3">

          {features.map((feature) => (

            <div

              key={feature}

              className="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"

            >

              {feature}

            </div>

          ))}

        </div>

      </CardHeader>

      <CardContent className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">

        <div className="space-y-4 rounded-[1.5rem] border border-white/10 bg-slate-950/35 p-5">

          <div className="space-y-2">

            <Label htmlFor={`${appName}-workspace`}>Workspace label</Label>

            <Input id={`${appName}-workspace`} defaultValue={`${appName}.workspace.local`} />

          </div>

          <Separator />

          <div className="flex flex-wrap gap-3">

            <Button>Primary action</Button>

            <Button variant="secondary">Secondary action</Button>

            <Button variant="outline">Outline action</Button>

          </div>

        </div>

        <div className="rounded-[1.5rem] border border-white/10 bg-white/5 p-5 text-sm leading-6 text-slate-200">

          <p>

            The same primitives can now be imported from the shared package and reused in both sub-apps without adding separate component copies.

          </p>

        </div>

      </CardContent>

      <CardFooter className="flex-wrap gap-3 border-t border-white/10 pt-6">

        <Button asChild variant="ghost">

          <a href="https://ui.shadcn.com/docs/components/button" target="_blank" rel="noopener noreferrer">

            View shadcn button reference

          </a>

        </Button>

        <Button asChild variant="outline">

          <a href="https://ui.shadcn.com/docs/components/card" target="_blank" rel="noopener noreferrer">

            View card reference

          </a>

        </Button>

      </CardFooter>

    </Card>

  )

}

这一步能顺带验证三件事:

  • 新增组件文件确实被 packages/ui/src/theme.css 扫描到了
  • @repo/ui/* 顶层子路径导入方式仍然成立
  • 共享包里的组件样式和结构在两个子站都能正确落地

8. 两个子站都要写一段更贴近业务的用法

只有共享演示块还不够。

因为它本质上还是一个通用展示区,没法完全说明组件进入真实页面结构后是否依旧正常。

所以这里分别在 apps/web/app/page.tsxapps/admin/app/page.tsx 再加一段更贴近业务的基础用法。

8.1 web

web 首页可以放一个简洁表单区块,重点验证 Label + Input + Button + Card + Separator 这一套组合:

tsx
// apps/web/app/page.tsx
import { Button } from '@repo/ui/button'

import {

  Card,

  CardContent,

  CardDescription,

  CardHeader,

  CardTitle,

} from '@repo/ui/card'

import { Input } from '@repo/ui/input'

import { Label } from '@repo/ui/label'

import { Separator } from '@repo/ui/separator'

import { TailwindDemo } from '@repo/ui/tailwind-demo'

export default function Home() {

  return (

    <>

      <TailwindDemo appName="web" />

      <Card>

        <CardHeader>

          <CardTitle>Primitive validation in web</CardTitle>

          <CardDescription>

            This 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>.

          </CardDescription>

        </CardHeader>

        <CardContent className="space-y-5">

          <div className="grid gap-2">

            <Label htmlFor="project-name">Project name</Label>

            <Input id="project-name" placeholder="AI Agent workspace" />

          </div>

          <Separator />

          <div className="flex flex-wrap gap-3">

            <Button>Save draft</Button>

            <Button variant="secondary">Preview</Button>

            <Button variant="outline">Open docs</Button>

          </div>

        </CardContent>

      </Card>

    </>

  )

}

这段的价值在于它更接近真实业务页面:有表单项、有分隔、有操作按钮,而且所有组件都直接从共享包导入。

8.2 admin

admin 首页则更适合做一个操作区,重点验证不同按钮尺寸和变体:

tsx
// apps/admin/app/page.tsx
import { Button } from '@repo/ui/button'

import {

  Card,

  CardContent,

  CardDescription,

  CardHeader,

  CardTitle,

} from '@repo/ui/card'

import { Input } from '@repo/ui/input'

import { Label } from '@repo/ui/label'

import { Separator } from '@repo/ui/separator'

import { TailwindDemo } from '@repo/ui/tailwind-demo'

export default function Home() {

  return (

    <>

      <TailwindDemo appName="admin" />

      <Card>

        <CardHeader>

          <CardTitle>Admin primitive validation</CardTitle>

          <CardDescription>

            This block checks the same shared primitives in a second app with a slightly different layout and button sizing.

          </CardDescription>

        </CardHeader>

        <CardContent className="space-y-5">

          <div className="grid gap-2 md:max-w-md">

            <Label htmlFor="tenant-id">Tenant identifier</Label>

            <Input id="tenant-id" placeholder="team-enterprise-01" />

          </div>

          <Separator />

          <div className="flex flex-wrap gap-3">

            <Button size="sm">Queue sync</Button>

            <Button variant="secondary">Inspect</Button>

            <Button size="lg" variant="outline">Publish changes</Button>

          </div>

        </CardContent>

      </Card>

    </>

  )

}

两个页面分开这样验证之后,能更明确地确认一件事:共享包里的基础组件不是只在一个演示块里可用,而是真的能跨应用消费。

9. 总结

这次不急着上 DialogPopoverDropdownMenu 这类更重的交互组件,原因很现实。

因为一旦开始做这类组件,主题变量、客户端边界、Portal、焦点管理、可访问性细节都会一起进来,文章复杂度会立刻上升。

当前阶段先把下面这些基础能力跑通,更值:

  • 按钮变体和尺寸
  • 表单输入与标签绑定
  • 结构容器和分隔线
  • cvacn、Radix primitives 的接入链路
  • 共享包被多个应用消费时的编译与样式稳定性

这批组件已经足够覆盖「共享包接入 shadcn 风格组件」最核心的路径。

处理完之后,我们可以按照下面的命令进行验证:

shellscript
// index.bash
pnpm install

pnpm --filter @repo/ui check-types

pnpm --filter web check-types

pnpm --filter admin check-types

pnpm lint

pnpm build

然后分别启动两个前端:

shellscript
// index.bash
pnpm --filter web dev

pnpm --filter admin dev

浏览器里打开:

  • http://localhost:3005
  • http://localhost:3006

检查:

  • 两个子站都能看到新的共享演示区和页面内业务区块
  • Button 的不同 variantsize 样式确实有差异
  • 点击 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 这类现有子路径有没有被破坏。

基于 MIT 协议开源