shadcn/ui完全ガイド — コピペで使えるReactコンポーネント集とカスタマイズ術


shadcn/uiは、2023年に登場してから爆発的に人気を博しているReactコンポーネントライブラリです。2026年現在、Next.js、Remix、Astroなど主要フレームワークで標準的に使われています。

この記事では、shadcn/uiの全コンポーネントとカスタマイズ方法を実践的なコード例とともに完全解説します。

shadcn/uiとは

shadcn/uiは「コンポーネントライブラリ」ではなく、コピー&ペーストできるコンポーネント集です。

従来のUIライブラリとの違い

# 従来のライブラリ(Material-UI、Chakra UI等)
npm install @mui/material
import { Button } from '@mui/material';

# shadcn/ui
npx shadcn@latest add button
# → components/ui/button.tsxにコードがコピーされる

shadcn/uiはパッケージをインストールしないのが最大の特徴です。コンポーネントのソースコードが直接プロジェクトにコピーされるため、自由にカスタマイズできます。

技術スタック

  • Radix UI - アクセシビリティ対応の低レベルUI
  • Tailwind CSS - スタイリング
  • class-variance-authority (CVA) - バリアント管理
  • clsx - クラス名の条件付き結合

プロジェクトセットアップ

Next.js 15プロジェクトの作成

npx create-next-app@latest my-shadcn-app --typescript --tailwind --app
cd my-shadcn-app

shadcn/uiの初期化

npx shadcn@latest init

対話形式で以下を選択:

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

これで以下のファイルが生成されます:

my-shadcn-app/
├── components/
│   └── ui/              # コンポーネントはここに配置
├── lib/
│   └── utils.ts         # cn()ユーティリティ
└── components.json      # shadcn設定

基本コンポーネント

Button

npx shadcn@latest add button
// app/page.tsx
import { Button } from '@/components/ui/button';

export default function Home() {
  return (
    <div className="p-8 space-y-4">
      {/* デフォルト */}
      <Button>Default</Button>

      {/* バリアント */}
      <Button variant="destructive">Destructive</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="link">Link</Button>

      {/* サイズ */}
      <Button size="sm">Small</Button>
      <Button size="default">Default</Button>
      <Button size="lg">Large</Button>
      <Button size="icon"></Button>

      {/* 状態 */}
      <Button disabled>Disabled</Button>
      <Button loading>Loading</Button>
    </div>
  );
}

カスタマイズ例

// components/ui/button.tsx
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md text-sm font-medium',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground',
        outline: 'border border-input hover:bg-accent',
        // カスタムバリアント追加
        success: 'bg-green-500 text-white hover:bg-green-600',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 px-3',
        lg: 'h-11 px-8',
        // カスタムサイズ追加
        xl: 'h-14 px-12 text-lg',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

// 使用例
<Button variant="success" size="xl">
  カスタムボタン
</Button>

フォームコンポーネント

Input、Label、Form

npx shadcn@latest add input label form
// app/login/page.tsx
'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,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';

const formSchema = z.object({
  email: z.string().email('有効なメールアドレスを入力してください'),
  password: z.string().min(8, 'パスワードは8文字以上必要です'),
});

export default function LoginPage() {
  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-8">
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>メールアドレス</FormLabel>
              <FormControl>
                <Input placeholder="you@example.com" {...field} />
              </FormControl>
              <FormDescription>
                ログインに使用するメールアドレスを入力してください。
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>パスワード</FormLabel>
              <FormControl>
                <Input type="password" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <Button type="submit">ログイン</Button>
      </form>
    </Form>
  );
}

Select、Checkbox、RadioGroup

npx shadcn@latest add select checkbox radio-group
// app/settings/page.tsx
'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 { Checkbox } from '@/components/ui/checkbox';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';

const formSchema = z.object({
  language: z.string(),
  theme: z.enum(['light', 'dark', 'system']),
  notifications: z.boolean(),
});

export default function SettingsPage() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      language: 'ja',
      theme: 'system',
      notifications: true,
    },
  });

  return (
    <Form {...form}>
      <form className="space-y-6">
        {/* Select */}
        <FormField
          control={form.control}
          name="language"
          render={({ field }) => (
            <FormItem>
              <FormLabel>言語</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="言語を選択" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="ja">日本語</SelectItem>
                  <SelectItem value="en">English</SelectItem>
                  <SelectItem value="zh">中文</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Radio Group */}
        <FormField
          control={form.control}
          name="theme"
          render={({ field }) => (
            <FormItem>
              <FormLabel>テーマ</FormLabel>
              <FormControl>
                <RadioGroup
                  onValueChange={field.onChange}
                  defaultValue={field.value}
                >
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="light" />
                    </FormControl>
                    <FormLabel className="font-normal">ライト</FormLabel>
                  </FormItem>
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="dark" />
                    </FormControl>
                    <FormLabel className="font-normal">ダーク</FormLabel>
                  </FormItem>
                  <FormItem className="flex items-center space-x-3 space-y-0">
                    <FormControl>
                      <RadioGroupItem value="system" />
                    </FormControl>
                    <FormLabel className="font-normal">システム</FormLabel>
                  </FormItem>
                </RadioGroup>
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        {/* Checkbox */}
        <FormField
          control={form.control}
          name="notifications"
          render={({ field }) => (
            <FormItem className="flex 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>通知を受け取る</FormLabel>
              </div>
            </FormItem>
          )}
        />

        <Button type="submit">保存</Button>
      </form>
    </Form>
  );
}

ナビゲーションコンポーネント

Dialog(モーダル)

npx shadcn@latest add dialog
// components/CreatePostDialog.tsx
'use client';

import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

export function CreatePostDialog() {
  const [open, setOpen] = useState(false);

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button>新規投稿</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="title" className="text-right">
              タイトル
            </Label>
            <Input id="title" className="col-span-3" />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="content" className="text-right">
              内容
            </Label>
            <Input id="content" className="col-span-3" />
          </div>
        </div>
        <DialogFooter>
          <Button type="submit" onClick={() => setOpen(false)}>
            作成
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
npx shadcn@latest add dropdown-menu
// components/UserMenu.tsx
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';

export function UserMenu() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline">アカウント</Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuLabel>マイアカウント</DropdownMenuLabel>
        <DropdownMenuSeparator />
        <DropdownMenuItem>プロフィール</DropdownMenuItem>
        <DropdownMenuItem>設定</DropdownMenuItem>
        <DropdownMenuItem>チーム</DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem className="text-red-600">
          ログアウト
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Sheet(サイドバー)

npx shadcn@latest add sheet
// components/MobileNav.tsx
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from '@/components/ui/sheet';
import { Button } from '@/components/ui/button';

export function MobileNav() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button variant="outline" size="icon">

        </Button>
      </SheetTrigger>
      <SheetContent side="left">
        <SheetHeader>
          <SheetTitle>メニュー</SheetTitle>
          <SheetDescription>
            ナビゲーションメニュー
          </SheetDescription>
        </SheetHeader>
        <nav className="mt-8 flex flex-col gap-4">
          <a href="/dashboard">ダッシュボード</a>
          <a href="/posts">投稿</a>
          <a href="/settings">設定</a>
        </nav>
      </SheetContent>
    </Sheet>
  );
}

データ表示コンポーネント

Table

npx shadcn@latest add table
// app/users/page.tsx
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';

const users = [
  { id: '1', name: '山田太郎', email: 'yamada@example.com', role: 'Admin' },
  { id: '2', name: '佐藤花子', email: 'sato@example.com', role: 'User' },
  { id: '3', name: '鈴木一郎', email: 'suzuki@example.com', role: 'User' },
];

export default function UsersPage() {
  return (
    <Table>
      <TableCaption>ユーザー一覧</TableCaption>
      <TableHeader>
        <TableRow>
          <TableHead>名前</TableHead>
          <TableHead>メールアドレス</TableHead>
          <TableHead>権限</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {users.map((user) => (
          <TableRow key={user.id}>
            <TableCell className="font-medium">{user.name}</TableCell>
            <TableCell>{user.email}</TableCell>
            <TableCell>{user.role}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

Card

npx shadcn@latest add card
// app/dashboard/page.tsx
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';

export default function DashboardPage() {
  return (
    <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
      <Card>
        <CardHeader>
          <CardTitle>総ユーザー数</CardTitle>
          <CardDescription>過去30日間の増加</CardDescription>
        </CardHeader>
        <CardContent>
          <p className="text-4xl font-bold">1,234</p>
          <p className="text-sm text-green-600">+12.5%</p>
        </CardContent>
        <CardFooter>
          <Button variant="outline">詳細</Button>
        </CardFooter>
      </Card>

      <Card>
        <CardHeader>
          <CardTitle>収益</CardTitle>
          <CardDescription>今月の売上</CardDescription>
        </CardHeader>
        <CardContent>
          <p className="text-4xl font-bold">¥567,890</p>
          <p className="text-sm text-green-600">+8.2%</p>
        </CardContent>
        <CardFooter>
          <Button variant="outline">詳細</Button>
        </CardFooter>
      </Card>
    </div>
  );
}

Badge、Avatar、Separator

npx shadcn@latest add badge avatar separator
// components/UserProfile.tsx
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';

export function UserProfile() {
  return (
    <div className="space-y-4">
      <div className="flex items-center gap-4">
        <Avatar>
          <AvatarImage src="https://github.com/shadcn.png" />
          <AvatarFallback>CN</AvatarFallback>
        </Avatar>
        <div>
          <h3 className="font-semibold">山田太郎</h3>
          <p className="text-sm text-muted-foreground">yamada@example.com</p>
        </div>
        <Badge>Pro</Badge>
      </div>

      <Separator />

      <div>
        <p>プロフィール情報...</p>
      </div>
    </div>
  );
}

テーマとカスタマイズ

カラースキーム変更

/* app/globals.css */
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
    --primary: 221.2 83.2% 53.3%;
    --primary-foreground: 210 40% 98%;
    /* ... */
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    --primary: 217.2 91.2% 59.8%;
    --primary-foreground: 222.2 47.4% 11.2%;
    /* ... */
  }
}

ダークモード切り替え

npm install 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>
  );
}
// components/ThemeToggle.tsx
'use client';

import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <Button
      variant="outline"
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
    >
      {theme === 'dark' ? '🌙' : '☀️'}
    </Button>
  );
}

実践的なデザインパターン

データテーブル(ソート・フィルタ付き)

npx shadcn@latest add table
npm install @tanstack/react-table
// app/posts/data-table.tsx
'use client';

import { useState } from 'react';
import {
  flexRender,
  getCoreRowModel,
  getSortedRowModel,
  useReactTable,
} from '@tanstack/react-table';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';

export function DataTable({ columns, data }) {
  const [sorting, setSorting] = useState([]);

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    onSortingChange: setSorting,
    state: {
      sorting,
    },
  });

  return (
    <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>
  );
}

ベストプラクティス

1. コンポーネントの再利用

// components/custom/ConfirmDialog.tsx
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';

interface ConfirmDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: string;
  description: string;
  onConfirm: () => void;
}

export function ConfirmDialog({
  open,
  onOpenChange,
  title,
  description,
  onConfirm,
}: ConfirmDialogProps) {
  return (
    <AlertDialog open={open} onOpenChange={onOpenChange}>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{title}</AlertDialogTitle>
          <AlertDialogDescription>{description}</AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>キャンセル</AlertDialogCancel>
          <AlertDialogAction onClick={onConfirm}>実行</AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

2. フォームの型安全

import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

type FormValues = z.infer<typeof schema>;

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

3. アクセシビリティ

shadcn/uiはRadix UIベースなので、アクセシビリティは自動的に確保されます。

// キーボード操作、ARIA属性、フォーカス管理が自動対応
<Dialog>
  <DialogTrigger>開く</DialogTrigger>
  <DialogContent>
    {/* ESCで閉じる、フォーカストラップ等が自動 */}
  </DialogContent>
</Dialog>

まとめ

shadcn/uiは、コピー&ペーストで使えるReactコンポーネント集です。

  • 自由なカスタマイズ - ソースコードが直接プロジェクトに
  • Tailwind CSS統合 - スタイリングが簡単
  • アクセシビリティ - Radix UIベース
  • 型安全 - TypeScriptフル対応
  • フォームバリデーション - Zod統合

Next.js 15 + shadcn/ui + Tailwind CSSの組み合わせで、高速で美しいUIを簡単に構築できます。まずはnpx shadcn@latest initから始めましょう。