Skip to content

shadcn/ui

shadcn/ui 不是传统的组件库——它不发布 npm 包,而是将组件源码直接复制到你的项目中。每个组件都是你自己的代码,可以随意修改,底层基于 Radix UI(无障碍原语)+ Tailwind CSS

核心理念:

  • 组件代码放在 components/ui/ 目录,完全可控
  • 基于 Radix UI 提供无障碍(a11y)支持
  • 使用 Tailwind CSS + CSS 变量实现主题
  • 按需添加,不安装用不到的组件

安装

前置要求

项目需要已配置 Tailwind CSS。以 Vite + React 为例:

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install tailwindcss @tailwindcss/vite

初始化 shadcn/ui

npx shadcn@latest init

交互式配置:

✔ Which style would you like to use? › Default
✔ Which color would you like to use as the base color? › Slate
✔ Would you like to use CSS variables for theming? › Yes

初始化后自动生成:

src/
├── components/
│   └── ui/            # 组件存放目录(后续 add 的组件放这里)
├── lib/
│   └── utils.ts       # cn() 工具函数
app/globals.css        # CSS 变量主题(或 src/index.css)
components.json        # shadcn 配置文件

添加组件

# 添加单个组件
npx shadcn@latest add button

# 添加多个组件
npx shadcn@latest add button input card dialog

# 添加所有组件
npx shadcn@latest add --all

主题系统

shadcn/ui 通过 CSS 变量实现主题,支持亮色/暗色模式切换:

/* globals.css */
@import "tailwindcss";

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --card: 0 0% 100%;
    --card-foreground: 222.2 84% 4.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 222.2 84% 4.9%;
    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;
    --secondary: 210 40% 96%;
    --secondary-foreground: 222.2 47.4% 11.2%;
    --muted: 210 40% 96%;
    --muted-foreground: 215.4 16.3% 46.9%;
    --accent: 210 40% 96%;
    --accent-foreground: 222.2 47.4% 11.2%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 40% 98%;
    --border: 214.3 31.8% 91.4%;
    --input: 214.3 31.8% 91.4%;
    --ring: 222.2 84% 4.9%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --primary: 210 40% 98%;
    --primary-foreground: 222.2 47.4% 11.2%;
    /* ... 其他暗色变量 */
  }
}

切换暗色模式

// 在 html 元素上切换 dark 类
document.documentElement.classList.toggle("dark")

// 搭配 next-themes
npm install next-themes

// providers.tsx
import { ThemeProvider } from "next-themes"

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
      {children}
    </ThemeProvider>
  )
}

// 切换按钮
import { useTheme } from "next-themes"

function ThemeToggle() {
  const { theme, setTheme } = useTheme()
  return (
    <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
      切换主题
    </button>
  )
}

自定义主题色

直接修改 CSS 变量即可更换整套主题色,推荐使用 shadcn/ui Themes 在线生成:

:root {
  --primary: 262.1 83.3% 57.8%;        /* 紫色主色 */
  --primary-foreground: 210 40% 98%;
  --ring: 262.1 83.3% 57.8%;
  --radius: 0.75rem;                    /* 更大的圆角 */
}

cn() 工具函数

shadcn 初始化时自动生成,用于合并 Tailwind 类名:

// src/lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Button 按钮

import { Button } from "@/components/ui/button"

// 变体
<Button>默认</Button>
<Button variant="destructive">危险操作</Button>
<Button variant="outline">边框</Button>
<Button variant="secondary">次要</Button>
<Button variant="ghost">幽灵</Button>
<Button variant="link">链接样式</Button>

// 尺寸
<Button size="sm">小</Button>
<Button size="default">默认</Button>
<Button size="lg">大</Button>
<Button size="icon"><SearchIcon /></Button>

// 加载状态
<Button disabled>
  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
  加载中...
</Button>

// 与 Link 结合(asChild 模式)
import { Link } from "react-router"

<Button asChild>
  <Link to="/dashboard">进入控制台</Link>
</Button>

查看/修改按钮源码

// components/ui/button.tsx(初始化后自动生成,可直接修改)
import { cva, type VariantProps } from "class-variance-authority"

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
        destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
        outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
        secondary: "bg-secondary text-secondary-foreground shadow-sm 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",
        sm: "h-8 rounded-md px-3 text-xs",
        lg: "h-10 rounded-md px-8",
        icon: "h-9 w-9",
      },
    },
    defaultVariants: { variant: "default", size: "default" },
  }
)

// 添加自定义变体
// 只需在 variants.variant 中添加即可:
// success: "bg-green-500 text-white hover:bg-green-600",

Input 输入框

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

// 基础
<Input placeholder="请输入邮箱" type="email" />

// 带 Label
<div className="grid w-full gap-1.5">
  <Label htmlFor="email">邮箱</Label>
  <Input id="email" type="email" placeholder="name@example.com" />
</div>

// 禁用
<Input disabled placeholder="禁用状态" />

// 带图标(Input 本身不含图标,需自己实现)
<div className="relative">
  <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
  <Input className="pl-9" placeholder="搜索..." />
</div>

// 带按钮
<div className="flex gap-2">
  <Input placeholder="输入邮箱订阅" />
  <Button type="submit">订阅</Button>
</div>

Card 卡片

import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"

<Card className="w-[380px]">
  <CardHeader>
    <CardTitle>账户设置</CardTitle>
    <CardDescription>修改你的账户信息和偏好设置</CardDescription>
  </CardHeader>
  <CardContent className="space-y-4">
    <div className="space-y-1.5">
      <Label htmlFor="name">用户名</Label>
      <Input id="name" defaultValue="张三" />
    </div>
    <div className="space-y-1.5">
      <Label htmlFor="email">邮箱</Label>
      <Input id="email" defaultValue="zhang@example.com" />
    </div>
  </CardContent>
  <CardFooter className="flex justify-between">
    <Button variant="outline">取消</Button>
    <Button>保存更改</Button>
  </CardFooter>
</Card>

Dialog 对话框

import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
  DialogClose,
} from "@/components/ui/dialog"

<Dialog>
  <DialogTrigger asChild>
    <Button variant="outline">打开对话框</Button>
  </DialogTrigger>
  <DialogContent className="sm:max-w-[425px]">
    <DialogHeader>
      <DialogTitle>编辑个人资料</DialogTitle>
      <DialogDescription>修改后点击保存,更改将立即生效。</DialogDescription>
    </DialogHeader>
    <div className="grid gap-4 py-4">
      <div className="grid grid-cols-4 items-center gap-4">
        <Label htmlFor="name" className="text-right">姓名</Label>
        <Input id="name" defaultValue="张三" className="col-span-3" />
      </div>
    </div>
    <DialogFooter>
      <DialogClose asChild>
        <Button variant="outline">取消</Button>
      </DialogClose>
      <Button type="submit">保存</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

受控 Dialog

function ConfirmDialog({ onConfirm }: { onConfirm: () => void }) {
  const [open, setOpen] = useState(false)

  function handleConfirm() {
    onConfirm()
    setOpen(false)
  }

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="destructive">删除账户</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>确认删除</DialogTitle>
          <DialogDescription>此操作不可撤销,账户数据将永久删除。</DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <Button variant="outline" onClick={() => setOpen(false)}>取消</Button>
          <Button variant="destructive" onClick={handleConfirm}>确认删除</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Sheet 侧边抽屉

import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
  SheetFooter,
  SheetClose,
} from "@/components/ui/sheet"

// side 控制从哪侧滑出:top | right(默认)| bottom | left
<Sheet>
  <SheetTrigger asChild>
    <Button variant="outline">打开侧边栏</Button>
  </SheetTrigger>
  <SheetContent side="right">
    <SheetHeader>
      <SheetTitle>筛选</SheetTitle>
      <SheetDescription>选择筛选条件后点击应用</SheetDescription>
    </SheetHeader>
    <div className="py-6 space-y-4">
      {/* 筛选内容 */}
    </div>
    <SheetFooter>
      <SheetClose asChild>
        <Button className="w-full">应用筛选</Button>
      </SheetClose>
    </SheetFooter>
  </SheetContent>
</Sheet>

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuCheckboxItem,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuShortcut,
} from "@/components/ui/dropdown-menu"

<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button variant="outline">
      我的账户 <ChevronDown className="ml-2 h-4 w-4" />
    </Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent align="end" className="w-56">
    <DropdownMenuLabel>我的账户</DropdownMenuLabel>
    <DropdownMenuSeparator />

    <DropdownMenuItem onClick={() => router.push("/profile")}>
      <User className="mr-2 h-4 w-4" />
      个人资料
      <DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
    </DropdownMenuItem>

    <DropdownMenuItem onClick={() => router.push("/settings")}>
      <Settings className="mr-2 h-4 w-4" />
      设置
    </DropdownMenuItem>

    {/* 子菜单 */}
    <DropdownMenuSub>
      <DropdownMenuSubTrigger>
        <UserPlus className="mr-2 h-4 w-4" />
        邀请成员
      </DropdownMenuSubTrigger>
      <DropdownMenuSubContent>
        <DropdownMenuItem>通过邮件邀请</DropdownMenuItem>
        <DropdownMenuItem>复制邀请链接</DropdownMenuItem>
      </DropdownMenuSubContent>
    </DropdownMenuSub>

    <DropdownMenuSeparator />
    <DropdownMenuItem className="text-destructive" onClick={logout}>
      <LogOut className="mr-2 h-4 w-4" />
      退出登录
    </DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

Select 选择器

import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

<Select onValueChange={(value) => console.log(value)}>
  <SelectTrigger className="w-[200px]">
    <SelectValue placeholder="选择框架" />
  </SelectTrigger>
  <SelectContent>
    <SelectGroup>
      <SelectLabel>前端框架</SelectLabel>
      <SelectItem value="react">React</SelectItem>
      <SelectItem value="vue">Vue</SelectItem>
      <SelectItem value="svelte">Svelte</SelectItem>
    </SelectGroup>
    <SelectGroup>
      <SelectLabel>后端框架</SelectLabel>
      <SelectItem value="nextjs">Next.js</SelectItem>
      <SelectItem value="remix">Remix</SelectItem>
    </SelectGroup>
  </SelectContent>
</Select>

// 受控
const [value, setValue] = useState("react")

<Select value={value} onValueChange={setValue}>
  ...
</Select>

Tabs 标签页

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"

<Tabs defaultValue="overview" className="w-full">
  <TabsList className="grid w-full grid-cols-3">
    <TabsTrigger value="overview">概览</TabsTrigger>
    <TabsTrigger value="analytics">分析</TabsTrigger>
    <TabsTrigger value="settings">设置</TabsTrigger>
  </TabsList>

  <TabsContent value="overview">
    <Card>
      <CardHeader><CardTitle>概览</CardTitle></CardHeader>
      <CardContent>概览内容...</CardContent>
    </Card>
  </TabsContent>

  <TabsContent value="analytics">
    <Card>
      <CardHeader><CardTitle>分析</CardTitle></CardHeader>
      <CardContent>分析图表...</CardContent>
    </Card>
  </TabsContent>

  <TabsContent value="settings">
    <Card>
      <CardHeader><CardTitle>设置</CardTitle></CardHeader>
      <CardContent>设置选项...</CardContent>
    </Card>
  </TabsContent>
</Tabs>

Form 表单(配合 react-hook-form)

shadcn 的 Form 组件深度封装了 react-hook-form + zod

npm install react-hook-form zod @hookform/resolvers
npx shadcn@latest add form
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"

// 1. 定义 Schema
const formSchema = z.object({
  username: z
    .string()
    .min(2, "用户名至少 2 个字符")
    .max(20, "用户名最多 20 个字符"),
  email: z.string().email("请输入有效邮箱"),
  age: z.coerce.number().int().min(1).max(120).optional(),
  role: z.enum(["admin", "user", "guest"], {
    required_error: "请选择角色",
  }),
  bio: z.string().max(200, "简介最多 200 字").optional(),
  notifications: z.boolean().default(false),
})

type FormValues = z.infer<typeof formSchema>

// 2. 构建表单
function ProfileForm() {
  const form = useForm<FormValues>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: "",
      email: "",
      role: "user",
      notifications: false,
    },
  })

  function onSubmit(values: FormValues) {
    console.log(values)
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">

        {/* 文本输入 */}
        <FormField
          control={form.control}
          name="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>用户名</FormLabel>
              <FormControl>
                <Input placeholder="请输入用户名" {...field} />
              </FormControl>
              <FormDescription>这将是你的公开显示名称。</FormDescription>
              <FormMessage />  {/* 自动显示验证错误 */}
            </FormItem>
          )}
        />

        {/* 邮箱 */}
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>邮箱</FormLabel>
              <FormControl>
                <Input type="email" placeholder="name@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Select */}
        <FormField
          control={form.control}
          name="role"
          render={({ field }) => (
            <FormItem>
              <FormLabel>角色</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="选择角色" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="admin">管理员</SelectItem>
                  <SelectItem value="user">普通用户</SelectItem>
                  <SelectItem value="guest">访客</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Checkbox */}
        <FormField
          control={form.control}
          name="notifications"
          render={({ field }) => (
            <FormItem className="flex items-center space-x-3 space-y-0 rounded-md border p-4">
              <FormControl>
                <Checkbox
                  checked={field.value}
                  onCheckedChange={field.onChange}
                />
              </FormControl>
              <div>
                <FormLabel>接收通知</FormLabel>
                <FormDescription>接收产品更新和安全提醒。</FormDescription>
              </div>
            </FormItem>
          )}
        />

        <Button type="submit" disabled={form.formState.isSubmitting}>
          {form.formState.isSubmitting && (
            <Loader2 className="mr-2 h-4 w-4 animate-spin" />
          )}
          提交
        </Button>
      </form>
    </Form>
  )
}

Table 表格

import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
  TableFooter,
} from "@/components/ui/table"

const invoices = [
  { id: "INV001", status: "已付款", method: "信用卡", amount: "¥250.00" },
  { id: "INV002", status: "待付款", method: "支付宝", amount: "¥150.00" },
  { id: "INV003", status: "已取消", method: "微信支付", amount: "¥350.00" },
]

<Table>
  <TableCaption>最近的账单列表</TableCaption>
  <TableHeader>
    <TableRow>
      <TableHead className="w-[100px]">账单号</TableHead>
      <TableHead>状态</TableHead>
      <TableHead>支付方式</TableHead>
      <TableHead className="text-right">金额</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    {invoices.map((inv) => (
      <TableRow key={inv.id}>
        <TableCell className="font-medium">{inv.id}</TableCell>
        <TableCell>{inv.status}</TableCell>
        <TableCell>{inv.method}</TableCell>
        <TableCell className="text-right">{inv.amount}</TableCell>
      </TableRow>
    ))}
  </TableBody>
  <TableFooter>
    <TableRow>
      <TableCell colSpan={3}>合计</TableCell>
      <TableCell className="text-right">¥750.00</TableCell>
    </TableRow>
  </TableFooter>
</Table>

Toast 轻提示(Sonner)

shadcn 推荐使用 sonner

npx shadcn@latest add sonner
// 在根布局中添加
import { Toaster } from "@/components/ui/sonner"

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Toaster richColors position="top-right" />
      </body>
    </html>
  )
}

// 在任意组件中使用
import { toast } from "sonner"

toast("操作成功")
toast.success("保存成功!")
toast.error("保存失败,请重试")
toast.warning("注意:此操作不可逆")
toast.info("系统将在 5 分钟后维护")

// 带描述
toast.success("文件已上传", {
  description: "photo.jpg 已成功上传到云端",
})

// 带操作按钮
toast("已删除文章", {
  action: {
    label: "撤销",
    onClick: () => undoDelete(),
  },
})

// 加载态(Promise)
toast.promise(uploadFile(file), {
  loading: "上传中...",
  success: "上传成功!",
  error: "上传失败",
})

Tooltip 工具提示

import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip"

// TooltipProvider 通常放在根组件
<TooltipProvider>
  <Tooltip>
    <TooltipTrigger asChild>
      <Button variant="outline" size="icon">
        <Settings className="h-4 w-4" />
      </Button>
    </TooltipTrigger>
    <TooltipContent side="bottom">
      <p>打开设置</p>
    </TooltipContent>
  </Tooltip>
</TooltipProvider>

Popover 弹出框

import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"

<Popover>
  <PopoverTrigger asChild>
    <Button variant="outline">打开</Button>
  </PopoverTrigger>
  <PopoverContent className="w-80" align="start">
    <div className="space-y-3">
      <h4 className="font-medium leading-none">尺寸</h4>
      <p className="text-sm text-muted-foreground">设置图层的尺寸。</p>
      <div className="grid grid-cols-3 items-center gap-4">
        <Label htmlFor="width">宽度</Label>
        <Input id="width" defaultValue="100%" className="col-span-2" />
      </div>
      <div className="grid grid-cols-3 items-center gap-4">
        <Label htmlFor="height">高度</Label>
        <Input id="height" defaultValue="25px" className="col-span-2" />
      </div>
    </div>
  </PopoverContent>
</Popover>

Badge 徽章

import { Badge } from "@/components/ui/badge"

<Badge>默认</Badge>
<Badge variant="secondary">次要</Badge>
<Badge variant="outline">边框</Badge>
<Badge variant="destructive">危险</Badge>

// 自定义颜色(直接加 className)
<Badge className="bg-green-500 hover:bg-green-600">已完成</Badge>
<Badge className="bg-yellow-500 hover:bg-yellow-600">进行中</Badge>

Avatar 头像

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"

<Avatar>
  <AvatarImage src="https://github.com/shadcn.png" alt="@shadcn" />
  <AvatarFallback>CN</AvatarFallback>  {/* 图片加载失败时显示 */}
</Avatar>

// 头像组(叠加显示)
<div className="flex -space-x-3">
  {users.map((user) => (
    <Avatar key={user.id} className="border-2 border-background">
      <AvatarImage src={user.avatar} alt={user.name} />
      <AvatarFallback>{user.name[0]}</AvatarFallback>
    </Avatar>
  ))}
  <div className="flex h-10 w-10 items-center justify-center rounded-full border-2 border-background bg-muted text-sm">
    +5
  </div>
</div>

Skeleton 骨架屏

import { Skeleton } from "@/components/ui/skeleton"

// 直接使用
<Skeleton className="h-4 w-[250px]" />
<Skeleton className="h-4 w-[200px]" />

// 卡片骨架
function CardSkeleton() {
  return (
    <div className="flex items-center space-x-4 p-4">
      <Skeleton className="h-12 w-12 rounded-full" />
      <div className="space-y-2">
        <Skeleton className="h-4 w-[250px]" />
        <Skeleton className="h-4 w-[200px]" />
      </div>
    </div>
  )
}

// 搭配 Suspense 使用
function UserList() {
  return (
    <Suspense fallback={<>
      <CardSkeleton />
      <CardSkeleton />
      <CardSkeleton />
    </>}>
      <AsyncUserList />
    </Suspense>
  )
}

Separator 分隔线

import { Separator } from "@/components/ui/separator"

<div>
  <p>上方内容</p>
  <Separator className="my-4" />
  <p>下方内容</p>
</div>

// 垂直分隔线
<div className="flex h-5 items-center space-x-4 text-sm">
  <div>博客</div>
  <Separator orientation="vertical" />
  <div>文档</div>
  <Separator orientation="vertical" />
  <div>源码</div>
</div>

Progress 进度条

import { Progress } from "@/components/ui/progress"

<Progress value={60} className="w-full" />

// 动态进度
function UploadProgress() {
  const [progress, setProgress] = useState(0)

  useEffect(() => {
    const timer = setInterval(() => {
      setProgress((p) => {
        if (p >= 100) { clearInterval(timer); return 100 }
        return p + 10
      })
    }, 500)
    return () => clearInterval(timer)
  }, [])

  return (
    <div className="space-y-2">
      <div className="flex justify-between text-sm">
        <span>上传中...</span>
        <span>{progress}%</span>
      </div>
      <Progress value={progress} />
    </div>
  )
}

扩展与自定义组件

shadcn 组件源码完全开放,扩展非常直接:

// 扩展 Button:添加 loading prop
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={loading || disabled} {...props}>
      {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
      {children}
    </Button>
  )
}
// 封装 FormField(减少重复代码)
import { useFormContext } from "react-hook-form"
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"

interface FormInputProps {
  name: string
  label: string
  placeholder?: string
  type?: string
}

export function FormInput({ name, label, placeholder, type = "text" }: FormInputProps) {
  const form = useFormContext()

  return (
    <FormField
      control={form.control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <FormLabel>{label}</FormLabel>
          <FormControl>
            <Input type={type} placeholder={placeholder} {...field} />
          </FormControl>
          <FormMessage />
        </FormItem>
      )}
    />
  )
}

// 使用
<Form {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
    <FormInput name="username" label="用户名" placeholder="请输入用户名" />
    <FormInput name="email" label="邮箱" type="email" placeholder="name@example.com" />
    <FormInput name="password" label="密码" type="password" />
    <Button type="submit">注册</Button>
  </form>
</Form>

常用组件速查

组件 安装命令 用途
Button add button 按钮
Input add input 输入框
Label add label 表单标签
Card add card 卡片容器
Dialog add dialog 模态对话框
Sheet add sheet 侧边抽屉
DropdownMenu add dropdown-menu 下拉菜单
Select add select 选择器
Tabs add tabs 标签页
Form add form 表单(含 rhf 集成)
Table add table 表格
Sonner add sonner Toast 提示
Tooltip add tooltip 工具提示
Popover add popover 弹出框
Badge add badge 徽章
Avatar add avatar 头像
Skeleton add skeleton 骨架屏
Separator add separator 分隔线
Progress add progress 进度条
Checkbox add checkbox 复选框
Switch add switch 开关
Slider add slider 滑块
RadioGroup add radio-group 单选组
Accordion add accordion 折叠面板
Collapsible add collapsible 可折叠区域
Calendar add calendar 日历
DatePicker add date-picker 日期选择器
Command add command 命令面板(⌘K)
Combobox add combobox 可搜索下拉
DataTable add data-table 数据表格(含排序/筛选)
NavigationMenu add navigation-menu 导航菜单
Breadcrumb add breadcrumb 面包屑
Pagination add pagination 分页
AlertDialog add alert-dialog 确认对话框
Alert add alert 警告提示
HoverCard add hover-card 悬停卡片
ScrollArea add scroll-area 自定义滚动区域
Resizable add resizable 可调整大小的面板