shadcn/ui 组件体系

shadcn/ui 不是一个 npm 包——它是一套可复制的组件源码。你拥有每一行代码,可以自由修改。配合 Tailwind CSS v4、Radix UI 无障碍基础、React Hook Form + Zod 表单校验,它是 Next.js 项目最推荐的 UI 方案。

1. 安装与配置

1.1 初始化

# 在 Next.js 项目中初始化 shadcn/ui
pnpm dlx shadcn@latest init

初始化向导会询问:

✔ Which style would you like to use? › New York
✔ Which color would you like to use as base color? › Zinc
✔ Would you like to use CSS variables for colors? › yes

生成的文件:

├── components.json          # shadcn/ui 配置文件
├── components/ui/           # 组件源码目录
├── lib/utils.ts             # cn() 工具函数
└── app/globals.css          # CSS 变量定义

1.2 添加组件

# 按需添加组件(源码复制到 components/ui/)
pnpm dlx shadcn@latest add button
pnpm dlx shadcn@latest add dialog
pnpm dlx shadcn@latest add form
pnpm dlx shadcn@latest add input
pnpm dlx shadcn@latest add select
pnpm dlx shadcn@latest add toast
pnpm dlx shadcn@latest add dropdown-menu
pnpm dlx shadcn@latest add sheet
 
# 批量添加
pnpm dlx shadcn@latest add button dialog form input select toast

1.3 核心工具:cn()

// lib/utils.ts
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
 
// 使用
<div className={cn(
  'rounded-lg border p-4',           // 基础样式
  isActive && 'border-primary',       // 条件样式
  className,                          // 外部传入的样式
)} />

2. 主题定制

2.1 CSS 变量体系

/* app/globals.css */
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 240 10% 3.9%;
    --card: 0 0% 100%;
    --card-foreground: 240 10% 3.9%;
    --primary: 240 5.9% 10%;
    --primary-foreground: 0 0% 98%;
    --secondary: 240 4.8% 95.9%;
    --muted: 240 4.8% 95.9%;
    --muted-foreground: 240 3.8% 46.1%;
    --accent: 240 4.8% 95.9%;
    --destructive: 0 84.2% 60.2%;
    --border: 240 5.9% 90%;
    --ring: 240 5.9% 10%;
    --radius: 0.5rem;
  }
 
  .dark {
    --background: 240 10% 3.9%;
    --foreground: 0 0% 98%;
    --primary: 0 0% 98%;
    --primary-foreground: 240 5.9% 10%;
    /* ... */
  }
}

2.2 品牌色定制

/* AI SaaS 品牌定制示例 */
:root {
  --primary: 262 83% 58%;            /* 紫色品牌色 */
  --primary-foreground: 0 0% 100%;
  --ring: 262 83% 58%;
}
 
.dark {
  --primary: 262 83% 68%;            /* 暗色模式稍亮 */
  --primary-foreground: 0 0% 100%;
}

2.3 暗色模式

pnpm add next-themes
// components/theme-provider.tsx
'use client'
 
import { ThemeProvider as NextThemesProvider } from 'next-themes'
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return (
    <NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
      {children}
    </NextThemesProvider>
  )
}
// components/theme-toggle.tsx
'use client'
 
import { useTheme } from 'next-themes'
import { Moon, Sun } from 'lucide-react'
import { Button } from '@/components/ui/button'
 
export function ThemeToggle() {
  const { theme, setTheme } = useTheme()
 
  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
    >
      <Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
      <Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
    </Button>
  )
}

3. 常用组件模式

3.1 确认对话框

// components/confirm-dialog.tsx
'use client'
 
import {
  AlertDialog, AlertDialogAction, AlertDialogCancel,
  AlertDialogContent, AlertDialogDescription,
  AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
  AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
 
export function ConfirmDialog({
  trigger,
  title,
  description,
  onConfirm,
  destructive = false,
}: {
  trigger: React.ReactNode
  title: string
  description: string
  onConfirm: () => void
  destructive?: boolean
}) {
  return (
    <AlertDialog>
      <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{title}</AlertDialogTitle>
          <AlertDialogDescription>{description}</AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>取消</AlertDialogCancel>
          <AlertDialogAction
            onClick={onConfirm}
            className={destructive ? 'bg-destructive text-destructive-foreground' : ''}
          >
            确认
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}
 
// 使用
<ConfirmDialog
  trigger={<Button variant="destructive" size="sm">删除</Button>}
  title="确定删除这个对话?"
  description="删除后无法恢复。"
  onConfirm={() => deleteChat(chatId)}
  destructive
/>

3.2 Toast 通知

// 使用 sonner(shadcn/ui 推荐的 toast 方案)
pnpm dlx shadcn@latest add sonner
// app/layout.tsx
import { Toaster } from '@/components/ui/sonner'
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Toaster />
      </body>
    </html>
  )
}
 
// 任何 Client Component 中使用
import { toast } from 'sonner'
 
toast.success('对话已创建')
toast.error('创建失败,请重试')
toast.loading('正在生成回复...')

3.3 响应式侧边栏

// components/app-sidebar.tsx
'use client'
 
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Menu } from 'lucide-react'
 
export function AppSidebar({ children }: { children: React.ReactNode }) {
  return (
    <>
      {/* 桌面端:固定侧边栏 */}
      <aside className="hidden md:flex md:w-64 md:flex-col border-r">
        {children}
      </aside>
 
      {/* 移动端:抽屉式侧边栏 */}
      <Sheet>
        <SheetTrigger asChild>
          <Button variant="ghost" size="icon" className="md:hidden">
            <Menu className="h-5 w-5" />
          </Button>
        </SheetTrigger>
        <SheetContent side="left" className="w-64 p-0">
          {children}
        </SheetContent>
      </Sheet>
    </>
  )
}

4. 表单系统(React Hook Form + Zod)

4.1 安装

pnpm dlx shadcn@latest add form input select textarea
pnpm add react-hook-form @hookform/resolvers zod

4.2 完整表单示例

// components/chat-settings-form.tsx
'use client'
 
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import {
  Form, FormControl, FormDescription,
  FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { toast } from 'sonner'
 
const formSchema = z.object({
  title: z.string().min(1, '标题不能为空').max(100),
  model: z.enum(['gpt-4o', 'claude-3.5-sonnet', 'gemini-pro']),
  systemPrompt: z.string().max(2000).optional(),
  temperature: z.coerce.number().min(0).max(2),
})
 
type FormValues = z.infer<typeof formSchema>
 
export function ChatSettingsForm({ onSubmit }: { onSubmit: (data: FormValues) => Promise<void> }) {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      title: '',
      model: 'gpt-4o',
      systemPrompt: '',
      temperature: 0.7,
    },
  })
 
  async function handleSubmit(data: FormValues) {
    try {
      await onSubmit(data)
      toast.success('设置已保存')
      form.reset()
    } catch {
      toast.error('保存失败')
    }
  }
 
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="title"
          render={({ field }) => (
            <FormItem>
              <FormLabel>对话标题</FormLabel>
              <FormControl>
                <Input placeholder="输入对话标题" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <FormField
          control={form.control}
          name="model"
          render={({ field }) => (
            <FormItem>
              <FormLabel>模型</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="选择模型" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="gpt-4o">GPT-4o</SelectItem>
                  <SelectItem value="claude-3.5-sonnet">Claude 3.5 Sonnet</SelectItem>
                  <SelectItem value="gemini-pro">Gemini Pro</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <FormField
          control={form.control}
          name="systemPrompt"
          render={({ field }) => (
            <FormItem>
              <FormLabel>System Prompt</FormLabel>
              <FormControl>
                <Textarea rows={4} placeholder="你是一个..." {...field} />
              </FormControl>
              <FormDescription>可选,用于设定 AI 的角色和行为。</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <FormField
          control={form.control}
          name="temperature"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Temperature: {field.value}</FormLabel>
              <FormControl>
                <Input type="range" min="0" max="2" step="0.1" {...field} />
              </FormControl>
              <FormDescription>越高越创造性,越低越确定性。</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
 
        <Button type="submit" disabled={form.formState.isSubmitting}>
          {form.formState.isSubmitting ? '保存中...' : '保存设置'}
        </Button>
      </form>
    </Form>
  )
}

5. 组件定制

5.1 修改源码

shadcn/ui 的组件源码在你的项目中——直接修改即可:

// components/ui/button.tsx — 修改按钮的默认样式
const buttonVariants = cva(
  'inline-flex items-center justify-center ...',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        // 新增:AI 品牌渐变按钮
        gradient: 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:opacity-90',
      },
      // ...
    },
  },
)

5.2 组合组件

// components/ui/loading-button.tsx
import { Button, type ButtonProps } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
 
interface LoadingButtonProps extends ButtonProps {
  loading?: boolean
}
 
export function LoadingButton({ loading, children, disabled, ...props }: LoadingButtonProps) {
  return (
    <Button disabled={disabled || loading} {...props}>
      {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
      {children}
    </Button>
  )
}

本章小结

  • shadcn/ui 是复制源码模式——你拥有完整控制权
  • CSS 变量:通过修改 globals.css 的 HSL 变量实现品牌定制
  • 暗色模式next-themes + attribute="class" 一行切换
  • 表单系统React Hook Form + Zod + shadcn Form 组件 = 类型安全的完整表单
  • 组件定制:直接修改源码或组合新组件,无需 override 或 CSS hack

下一章讲 Layout 与模板系统。