shadcn-ui について
ReactやNext.jsの開発者向けに、shadcn/uiのコンポーネント、スタイル、テーマ、アクセシビリティの使い方を案内するスキルです。コンポーネントはパッケージとして導入せず、CLIでプロジェクトにコピーして所有・カスタマイズします。
主な機能
- 01CLIでコンポーネントをプロジェクトにコピー
- 02Tailwind CSSの変数によるテーマ設定
- 03ダークモード設定の案内
- 04フォーム、テーブル、ダイアログの解説
- 05Radixベースのアクセシビリティパターン
活用シーン
- 01Reactプロジェクトへの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 構築の包括的なガイドです。shadcn/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 の場合と同じですが、Pages Router は React Server Components をサポートしていないため、components.json で rsc: false を設定します。
コンポーネントの追加
# 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を設定し忘れないでください。 - インタラクティブ要素を入れ子にしないでください(ボタン内のボタン、ボタン内のリンクなど)。
厳守事項
- shadcn/ui をパッケージ依存関係としてインストールしては絶対にいけません。コンポーネントは完全に所有できるよう、プロジェクトにコピーする必要があります
- 条件付きのクラス名には必ず
cn()ユーティリティを使用し、Tailwind のクラス競合を防いでください - 色をハードコードしては絶対にいけません。テーマの一貫性を保つため、必ず CSS 変数のテーマトークンを使用してください
- Radix UI プリミティブは必ず shadcn/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 を使う場合は <html> に suppressHydrationWarning を追加します |
メモリープロトコル(必須)
開始前:
.claude/context/memory/learnings.md を読みます
完了後:
- 新しいパターン ->
.claude/context/memory/learnings.md - 発見した問題 ->
.claude/context/memory/issues.md - 決定事項 ->
.claude/context/memory/decisions.md
中断されることを前提としてください。メモリーに記録されていなければ、実行されていないものとします。