关于 shadcn-ui
面向 React 和 Next.js 开发者的技能,指导使用 shadcn/ui 组件、样式、主题及无障碍模式。组件由 CLI 复制到项目中,便于直接拥有和定制,而非作为软件包安装。
主要功能
- 01通过 CLI 将组件复制到项目
- 02使用 Tailwind CSS 变量主题
- 03指导设置深色模式
- 04涵盖表单、表格和对话框
- 05介绍基于 Radix 的无障碍模式
适用场景
- 01向 React 项目添加 shadcn/ui 组件
- 02设置深色模式和主题令牌
- 03构建无障碍表单和对话框
- 04选择组件或自定义 UI
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ oimiragieo/ agent-studio --skill shadcn-ui安装后在 Claude Code 中输入/shadcn-ui确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
commands/shadcn-ui.mdhooks/post-execute.cjspre-execute.cjsreferences/research-requirements.mdrules/shadcn-ui.mdschemas/input.schema.jsonoutput.schema.jsonscripts/main.cjstemplates/implementation-template.md详细介绍
shadcn/ui 专家指南
全面介绍如何使用 shadcn/ui 构建 UI——这是一个基于 Radix UI 原语和 Tailwind CSS 构建的复制粘贴式组件库。组件不会作为依赖项安装,而是复制到你的项目中,供你完全掌控和自定义。
适用场景
在以下情况下使用此技能:
- 向 React 或 Next.js 项目添加 shadcn/ui 组件
- 自定义组件样式、变体或行为
- 使用 CSS 变量设置 Tailwind CSS v4 主题
- 使用 shadcn/ui 实现深色模式
- 构建无障碍表单、对话框或数据表格
- 在 shadcn/ui 组件和自定义实现之间进行选择
核心概念
shadcn/ui 不是组件库
shadcn/ui 是一组可复用组件,你可以将其复制到自己的项目中。它与传统组件库的主要区别:
- 无需 npm 包依赖——组件位于你的代码库中
- 完全掌控——可自由修改任何组件
- Radix UI 原语——底层是无障碍、无样式的无头组件
- Tailwind CSS——所有样式均通过实用类和 CSS 变量实现
- 由 CLI 驱动——
npx shadcn@latest add button会复制组件代码
架构
Your Project
components/
ui/ <- shadcn/ui components live here
button.tsx
dialog.tsx
input.tsx
...
lib/
utils.ts <- cn() utility (clsx + tailwind-merge)
设置
Next.js App Router(推荐)
# Initialize shadcn/ui in existing Next.js project
npx shadcn@latest init
# This creates:
# - components.json (configuration)
# - lib/utils.ts (cn utility)
# - Tailwind CSS variable theme in globals.css
components.json 配置:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
Vite + React
# Initialize
npx shadcn@latest init
# Vite requires path aliases in vite.config.ts:
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
Next.js Pages Router
与 App Router 相同,但需在 components.json 中设置 rsc: false,因为 Pages Router 不支持 React Server Components。
添加组件
# Add a single component
npx shadcn@latest add button
# Add multiple components
npx shadcn@latest add button card input label
# Add all components
npx shadcn@latest add --all
# View available components
npx shadcn@latest add --list
cn() 实用函数
每个 shadcn/ui 组件都使用 cn() 来合并条件类名:
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
cn() 将 clsx(条件类名)与 tailwind-merge(解决 Tailwind 冲突)结合起来:
<Button
className={cn(
'bg-primary text-white',
isDisabled && 'opacity-50 cursor-not-allowed',
size === 'lg' && 'px-8 py-4 text-lg'
)}
>
Submit
</Button>
使用 CSS 变量设置主题
Tailwind CSS v4 主题设置
shadcn/ui 使用 CSS 自定义属性设置主题,从而支持运行时切换主题:
/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
深色模式实现
在 Next.js 中使用 next-themes 实现深色模式:
// app/providers.tsx
'use client';
import { ThemeProvider } from 'next-themes';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
切换组件:
'use client';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import { Moon, Sun } from 'lucide-react';
export function ThemeToggle() {
const { setTheme, theme } = 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" />
<span className="sr-only">Toggle theme</span>
</Button>
);
}
常见组件模式
使用 React Hook Form + Zod 构建表单
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
const formSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
export function LoginForm() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { email: '', password: '' },
});
function onSubmit(values: z.infer<typeof formSchema>) {
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="[email protected]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Sign In</Button>
</form>
</Form>
);
}
使用 TanStack Table 构建数据表格
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(header => (
<TableHead key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map(row => (
<TableRow key={row.id}>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
响应式对话框/抽屉模式
在桌面端使用 Dialog,在移动端使用 Drawer:
'use client';
import { useMediaQuery } from '@/hooks/use-media-query';
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog';
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer';
import { Button } from '@/components/ui/button';
export function ResponsiveModal({ children }: { children: React.ReactNode }) {
const isDesktop = useMediaQuery('(min-width: 768px)');
if (isDesktop) {
return (
<Dialog>
<DialogTrigger asChild>
<Button>Open</Button>
</DialogTrigger>
<DialogContent>{children}</DialogContent>
</Dialog>
);
}
return (
<Drawer>
<DrawerTrigger asChild>
<Button>Open</Button>
</DrawerTrigger>
<DrawerContent>{children}</DrawerContent>
</Drawer>
);
}
无障碍模式
shadcn/ui 组件基于 Radix UI 构建,后者提供:
- 完整的键盘导航(Tab、方向键、Enter、Escape)
- ARIA 属性(角色、状态、属性)
- 焦点管理(焦点陷阱、焦点恢复)
- 屏幕阅读器播报
各组件的关键无障碍特性
| 组件 | 键盘 | ARIA | 焦点陷阱 |
|---|---|---|---|
| Button | 按 Enter/Space 激活 | role="button" | 否 |
| Dialog | 按 Escape 关闭 | role="dialog", aria-modal | 是 |
| Dropdown Menu | 使用方向键导航 | role="menu", role="menuitem" | 是 |
| Select | 方向键、按键搜索 | role="listbox", role="option" | 是 |
| Tabs | 使用方向键切换标签页 | role="tablist", role="tab" | 否 |
| Toast | 自动播报 | role="status", aria-live | 否 |
| Tooltip | 聚焦/悬停时显示 | role="tooltip" | 否 |
自定义无障碍增强
// Always provide labels for interactive elements
<Button aria-label="Close dialog">
<X className="h-4 w-4" />
</Button>
// Use sr-only for visual-only content
<span className="sr-only">Loading...</span>
// Announce dynamic content
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
反模式
- 不要将 shadcn/ui 安装为 npm 包——使用 CLI 复制组件
- 不要直接修改 Radix 原语——通过 shadcn 包装组件扩展
- 不要使用硬编码颜色——始终使用 CSS 变量主题令牌
- 不要跳过
cn()实用函数——它可以防止 Tailwind 类冲突 - 使用 next-themes 时,不要忘记在
<html>上添加suppressHydrationWarning - 不要嵌套交互元素(例如在 button 中放置 button,或在 button 中放置链接)
铁律
- 绝不将 shadcn/ui 安装为软件包依赖项——组件必须复制到项目中,以便完全拥有
- 始终使用
cn()工具处理条件类名,避免 Tailwind 类冲突 - 绝不硬编码颜色——始终使用 CSS 变量主题令牌,确保主题一致性
- 始终通过 shadcn/ui 抽象层使用 Radix UI 基元,不要直接使用
- 绝不嵌套交互元素(按钮中放按钮、按钮中放链接)——这违反无障碍标准
反模式
| 反模式 | 失败原因 | 正确做法 |
|---|---|---|
| 作为软件包安装 | 组件源码被锁定,无法自定义 | 使用 npx shadcn@latest add 将组件复制到项目中 |
| 硬编码颜色值 | 主题切换会失效;深色模式无法正常工作 | 使用 CSS 变量令牌(bg-background、text-foreground 等) |
跳过 cn() 工具 | Tailwind 类冲突会导致样式不可预测 | 始终使用 @/lib/utils 中的 cn() 合并类名 |
| 直接使用 Radix UI 基元 | 缺少 shadcn 样式和无障碍功能配置 | 使用封装 Radix 基元并包含正确类名的 shadcn 组件 |
缺少 suppressHydrationWarning | 使用 next-themes 深色模式时会出现水合不匹配错误 | 使用 next-themes 时,将 suppressHydrationWarning 添加到 <html> |
记忆协议(强制)
开始之前:
阅读 .claude/context/memory/learnings.md
完成之后:
- 新模式 ->
.claude/context/memory/learnings.md - 发现问题 ->
.claude/context/memory/issues.md - 做出决策 ->
.claude/context/memory/decisions.md
**假设会发生中断:**如果没有记录在记忆中,就等于没有发生。