ClawSkills logoClawSkills

Shadcn Ui

在构建 UI 时使用,包括 shadcn/ui 组件、Tailwind CSS 布局、使用 react-hook-form 和 zod 的表单模式、主题、深色模式、侧边栏布局、移动端导

介绍

# shadcn/ui Expert

使用 shadcn/ui、Tailwind CSS、react-hook-form 和 zod 构建生产级 UI 的综合指南。

## 核心概念

shadcn/ui **不是**一个组件库 —— 它是一组基于 Radix UI 原语构建的复制粘贴组件。你拥有代码的所有权。组件是被添加到你的项目中,而不是作为依赖安装。

## 安装

```bash # Initialize shadcn/ui in a Next.js project npx shadcn@latest init

# Add individual components npx shadcn@latest add button npx shadcn@latest add card npx shadcn@latest add dialog npx shadcn@latest add form npx shadcn@latest add input npx shadcn@latest add select npx shadcn@latest add table npx shadcn@latest add toast npx shadcn@latest add dropdown-menu npx shadcn@latest add sheet npx shadcn@latest add tabs npx shadcn@latest add sidebar

# Add multiple at once npx shadcn@latest add button card input label textarea select checkbox ```

---

## 组件分类与使用场景

### 布局与导航 | 组件 | 使用场景 | |-----------|----------| | `sidebar` | 应用级导航,带有可折叠部分 | | `navigation-menu` | 带有下拉菜单的顶级站点导航 | | `breadcrumb` | 显示页面层级/位置 | | `tabs` | 在相同上下文中切换相关视图 | | `separator` | 内容部分之间的视觉分隔符 | | `sheet` | 侧滑面板(移动端导航、过滤器、详情视图) | | `resizable` | 可调整面板布局 |

### 表单与输入 | 组件 | 使用场景 | |-----------|----------| | `form` | 任何带有验证的表单(封装 react-hook-form) | | `input` | 文本、邮箱、密码、数字输入 | | `textarea` | 多行文本输入 | | `select` | 从列表中选择(类原生) | | `combobox` | 可搜索选择(使用 `command` + `popover`) | | `checkbox` | 布尔或多选切换 | | `radio-group` | 小集合中的单选 | | `switch` | 开/关切换(设置、偏好) | | `slider` | 数值范围选择 | | `date-picker` | 日期选择(使用 `calendar` + `popover`) | | `toggle` | 按下/未按下状态(工具栏按钮) |

### 反馈与浮层 | 组件 | 使用场景 | |-----------|----------| | `dialog` | 模态确认、表单或详情视图 | | `alert-dialog` | 破坏性操作确认(“你确定吗?”) | | `sheet` | 表单、过滤器、移动端导航的侧边栏 | | `toast` | 简短的非阻塞通知(通过 `sonner`) | | `alert` | 内联状态消息(信息、警告、错误) | | `tooltip` | 图标/按钮的悬停提示 | | `popover` | 点击时的富内容(颜色选择器、日期选择器) | | `hover-card` | 悬停时的内容预览(用户资料、链接) | | `skeleton` | 加载占位符 | | `progress` | 任务完成指示器 |

### 数据展示 | 组件 | 使用场景 | |-----------|----------| | `table` | 表格数据展示 | | `data-table` | 带有排序、过滤、分页的表格(使用 `@tanstack/react-table`) | | `card` | 带有页眉、主体、页脚的内容容器 | | `badge` | 状态标签、标记、计数 | | `avatar` | 用户个人资料图片 | | `accordion` | 可折叠的常见问题或设置部分 | | `carousel` | 图片/内容轮播 | | `scroll-area` | 自定义可滚动容器 |

### 操作 | 组件 | 使用场景 | |-----------|----------| | `button` | 主要操作、表单提交 | | `dropdown-menu` | 上下文菜单、操作菜单 | | `context-menu` | 右键菜单 | | `menubar` | 应用菜单栏 | | `command` | 命令面板 / 搜索 (⌘K) |

---

## 表单模式 (react-hook-form + zod)

### 完整表单示例

```bash npx shadcn@latest add form input select textarea checkbox button ```

```tsx 'use client'

import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' import { z } from 'zod' import { Button } from '@/components/ui/button' import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Checkbox } from '@/components/ui/checkbox' import { toast } from 'sonner'

const formSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Invalid email address'), role: z.enum(['admin', 'user', 'editor'], { required_error: 'Select a role' }), bio: z.string().max(500).optional(), notifications: z.boolean().default(false), })

type FormValues = z.infer<typeof formSchema>

export function UserForm() { const form = useForm<FormValues>({ resolver: zodResolver(formSchema), defaultValues: { name: '', email: '', bio: '', notifications: false, }, })

async function onSubmit(values: FormValues) { try { await createUser(values) toast.success('User created successfully') form.reset() } catch (error) { toast.error('Failed to create user') } }

return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormLabel>Name</FormLabel> <FormControl> <Input placeholder="John Doe" {...field} /> </FormControl> <FormMessage /> </FormItem> )} />

<FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormLabel>Email</FormLabel> <FormControl> <Input type="email" placeholder="[email protected]" {...field} /> </FormControl> <FormMessage /> </FormItem> )} />

<FormField control={form.control} name="role" render={({ field }) => ( <FormItem> <FormLabel>Role</FormLabel> <Select onValueChange={field.onChange} defaultValue={field.value}> <FormControl> <SelectTrigger> <SelectValue placeholder="Select a role" /> </SelectTrigger> </FormControl> <SelectContent> <SelectItem value="admin">Admin</SelectItem> <SelectItem value="editor">Editor</SelectItem> <SelectItem value="user">User</SelectItem> </SelectContent> </Select> <FormMessage /> </FormItem> )} />

<FormField control={form.control} name="bio" render={({ field }) => ( <FormItem> <FormLabel>Bio</FormLabel> <FormControl> <Textarea placeholder="Tell us about yourself..." {...field} /> </FormControl> <FormDescription>Max 500 characters</FormDescription> <FormMessage /> </FormItem> )} />

<FormField control={form.control} name="notifications" render={({ field }) => ( <FormItem className="flex flex-row items-start space-x-3 space-y-0"> <FormControl> <Checkbox checked={field.value} onCheckedChange={field.onChange} /> </FormControl> <div className="space-y-1 leading-none"> <FormLabel>Email notifications</FormLabel> <FormDescription>Receive emails about account activity</FormDescription> </div> </FormItem> )} />

<Button type="submit" disabled={form.formState.isSubmitting}> {form.formState.isSubmitting ? 'Creating...' : 'Create User'} </Button> </form> </Form> ) } ```

### 带有 Server Action 的表单

```tsx 'use client'

import { useFormState } from 'react-dom' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod'

export function ContactForm() { const form = useForm<FormValues>({ resolver: zodResolver(schema), })

async function onSubmit(values: FormValues) { const formData = new FormData() Object.entries(values).forEach(([key, value]) => formData.append(key, String(value))) await submitContact(formData) }

return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> {/* fields */} </form> </Form> ) } ```

---

## 主题与深色模式

### 使用 next-themes 设置

```bash npm install next-themes npx shadcn@latest add dropdown-menu ```

```tsx // app/providers.tsx 'use client' import { ThemeProvider } from 'next-themes'

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

```tsx // components/theme-toggle.tsx 'use client' import { Moon, Sun } from 'lucide-react' import { useTheme } from 'next-themes' import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'

export function ThemeToggle() { const { setTheme } = useTheme() return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="icon"> <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <span className="sr-only">Toggle theme</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => setTheme('light')}>Light</DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('dark')}>Dark</DropdownMenuItem> <DropdownMenuItem onClick={() => setTheme('system')}>System</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ) } ```

### `globals.css` 中的自定义颜色

```css @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --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%; --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%; /* ... etc */ } } ```

---

## 常见布局

### 带侧边栏的应用外壳

```tsx import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' import { AppSidebar } from '@/components/app-sidebar'

export default function DashboardLayout({ children }: { children: React.ReactNode }) { return ( <SidebarProvider> <AppSidebar /> <main className="flex-1"> <header className="flex h-14 items-center gap-4 border-b px-6"> <SidebarTrigger /> <h1 className="text-lg font-semibold">Dashboard</h1> </header> <div className="p-6">{children}</div> </main> </SidebarProvider> ) } ```

### 带移动端导航的响应式页头

```tsx import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet' import { Button } from '@/components/ui/button' import { Menu } from 'lucide-react'

export function Header() { return ( <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur"> <div className="container flex h-14 items-center"> <div className="mr-4 hidden md:flex"> <Logo /> <nav className="flex items-center gap-6 text-sm ml-6"> <Link href="/dashboard">Dashboard</Link> <Link href="/settings">Settings</Link> </nav> </div>

{/* Mobile hamburger */} <Sheet> <SheetTrigger asChild> <Button variant="outline" size="icon" className="md:hidden"> <Menu className="h-5 w-5" /> </Button> </SheetTrigger> <SheetContent side="left" className="w-[300px]"> <nav className="flex flex-col gap-4 mt-8"> <Link href="/dashboard">Dashboard</Link> <Link href="/settings">Settings</Link> </nav> </SheetContent> </Sheet>

<div className="flex flex-1 items-center justify-end gap-2"> <ThemeToggle /> <UserMenu /> </div> </div> </header> ) } ```

### 卡片网格

```tsx import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'

export function StatsGrid({ stats }: { stats: Stat[] }) { return ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> {stats.map((stat) => ( <Card key={stat.label}> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardTitle className="text-sm font-medium">{stat.label}</CardTitle> <stat.icon className="h-4 w-4 text-muted-foreground" /> </CardHeader> <CardContent> <div className="text-2xl font-bold">{stat.value}</div> <p className="text-xs text-muted-foreground">{stat.description}</p> </CardContent> </Card> ))} </div> ) } ```

---

## Tailwind CSS 模式

### 常用工具类模式

```tsx // Centering <div className="flex items-center justify-center min-h-screen">

// Container with max-width <div className="container mx-auto px-4">

// Responsive grid <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">

// Sticky header <header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur">

// Truncated text <p className="truncate">Very long text...</p>

// Line clamp <p className="line-clamp-3">Multi-line truncation...</p>

// Aspect ratio <div className="aspect-video rounded-lg overflow-hidden">

// Animations <div className="animate-pulse"> {/* Loading skeleton */} <div className="animate-spin"> {/* Spinner */} <div className="transition-all duration-200 hover:scale-105"> ```

### 按钮变体

```tsx <Button>Default</Button> <Button variant="secondary">Secondary</Button> <Button variant="outline">Outline</Button> <Button variant="ghost">Ghost</Button> <Button variant="link">Link</Button> <Button variant="destructive">Delete</Button> <Button size="sm">Small</Button> <Button size="lg">Large</Button> <Button size="icon"><Plus className="h-4 w-4" /></Button> <Button disabled>Disabled</Button> <Button asChild><Link href="/page">As Link</Link></Button> ```

---

## Toast 通知

```bash npx shadcn@latest add sonner ```

```tsx // app/layout.tsx import { Toaster } from '@/components/ui/sonner'

export default function RootLayout({ children }) { return ( <html><body>{children}<Toaster /></body></html> ) }

// Usage anywhere import { toast } from 'sonner'

toast.success('User created') toast.error('Something went wrong') toast.info('New update available') toast.warning('This action cannot be undone') toast.promise(asyncAction(), { loading: 'Creating...', success: 'Created!', error: 'Failed to create', }) ```

---

## 命令面板 (⌘K)

```tsx 'use client' import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '@/components/ui/command'

export function CommandPalette() { const [open, setOpen] = useState(false) const router = useRouter()

useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault() setOpen((open) => !open) } } document.addEventListener('keydown', down) return () => document.removeEventListener('keydown', down) }, [])

return ( <CommandDialog open={open} onOpenChange={setOpen}> <CommandInput placeholder="Type a command or search..." /> <CommandList> <CommandEmpty>No results found.</CommandEmpty> <CommandGroup heading="Navigation"> <CommandItem onSelect={() => { router.push('/dashboard'); setOpen(false) }}> Dashboard </CommandItem> <CommandItem onSelect={() => { router.push('/settings'); setOpen(false) }}> Settings </CommandItem> </CommandGroup> </CommandList> </CommandDialog> ) } ```

更多产品