DT ツール・運用 shadcn/ui完全ガイド -

shadcn/ui完全ガイド - コピペで実装する美しいUIコンポーネント

shadcn/uiの特徴、導入方法、カスタマイズ手法を徹底解説。Radix UIとTailwind CSSを活用したモダンなコンポーネントライブラリの実践的な使い方

約5分で読めます
技術記事
実践的

この記事のポイント

shadcn/uiの特徴、導入方法、カスタマイズ手法を徹底解説。Radix UIとTailwind CSSを活用したモダンなコンポーネントライブラリの実践的な使い方

この記事では、実践的なアプローチで技術的な課題を解決する方法を詳しく解説します。具体的なコード例とともに、ベストプラクティスを学ぶことができます。

shadcn/uiは、コピー&ペーストで使えるReactコンポーネントの集合体として、2023年から急速に人気を集めているUIライブラリです。従来のnpmパッケージとは異なるアプローチで、開発者に完全な制御権を提供します。

shadcn/uiとは

graph TD
    A[shadcn/ui] --> B[Radix UI Primitives]
    A --> C[Tailwind CSS]
    A --> D[コピペ可能なコード]
    B --> E[アクセシビリティ]
    C --> F[スタイリング]
    D --> G[完全なカスタマイズ性]

主な特徴

  1. 依存関係なし - npmパッケージではなく、コードをプロジェクトに直接コピー
  2. 完全なカスタマイズ性 - すべてのコードが手元にあるため、自由に編集可能
  3. Radix UI基盤 - アクセシビリティが考慮された堅牢な実装
  4. Tailwind CSS - ユーティリティファーストのスタイリング
  5. TypeScript対応 - 型安全な開発が可能

セットアップ

1. 前提条件

{
  "dependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0",
    "tailwindcss": "^3.0.0"
  }
}

2. 初期設定

# shadcn/ui CLIのインストール
npx shadcn-ui@latest init

# 設定ファイルが生成される
 Would you like to use TypeScript? yes
 Which style would you like to use? Default
 Which color would you like to use as base color? Slate
 Where is your global CSS file? app/globals.css
 Would you like to use CSS variables for colors? yes
 Where is your tailwind.config.js located? tailwind.config.js
 Configure the import alias for components? @/components
 Configure the import alias for utils? @/lib/utils

3. components.json

生成される設定ファイル:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.js",
    "css": "app/globals.css",
    "baseColor": "slate",
    "cssVariables": true
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils"
  }
}

コンポーネントの導入

CLIを使用したインストール

# 単一コンポーネント
npx shadcn-ui@latest add button

# 複数コンポーネント
npx shadcn-ui@latest add dialog card table

# すべてのコンポーネント
npx shadcn-ui@latest add --all

生成されるファイル構造

components/
└── ui/
    ├── button.tsx
    ├── dialog.tsx
    ├── card.tsx
    └── table.tsx

主要コンポーネントの使い方

Button

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

export function ButtonDemo() {
  return (
    <div className="flex gap-4">
      <Button>Default</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="destructive">Destructive</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="link">Link</Button>
      <Button size="sm">Small</Button>
      <Button size="lg">Large</Button>
      <Button disabled>Disabled</Button>
      <Button asChild>
        <a href="/home">Link Button</a>
      </Button>
    </div>
  )
}

Form(React Hook Form + Zod)

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({
  username: z.string().min(2, {
    message: "ユーザー名は2文字以上で入力してください",
  }),
  email: z.string().email({
    message: "有効なメールアドレスを入力してください",
  }),
})

export function ProfileForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      username: "",
      email: "",
    },
  })

  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="username"
          render={({ field }) => (
            <FormItem>
              <FormLabel>ユーザー名</FormLabel>
              <FormControl>
                <Input placeholder="shadcn" {...field} />
              </FormControl>
              <FormDescription>
                公開されるユーザー名です
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>メールアドレス</FormLabel>
              <FormControl>
                <Input placeholder="email@example.com" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">送信</Button>
      </form>
    </Form>
  )
}

データテーブル(Tanstack Table統合)

import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

interface User {
  id: string
  name: string
  email: string
  role: string
}

const columns: ColumnDef<User>[] = [
  {
    accessorKey: "name",
    header: "名前",
  },
  {
    accessorKey: "email",
    header: "メールアドレス",
  },
  {
    accessorKey: "role",
    header: "役割",
  },
]

export function DataTableDemo({ data }: { data: User[] }) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

  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?.length ? (
          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>
          ))
        ) : (
          <TableRow>
            <TableCell colSpan={columns.length} className="h-24 text-center">
              データがありません
            </TableCell>
          </TableRow>
        )}
      </TableBody>
    </Table>
  )
}

カスタマイズ手法

1. テーマのカスタマイズ

/* 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%;
    --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%;
    --input: 214.3 31.8% 91.4%;
    --ring: 221.2 83.2% 53.3%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 222.2 84% 4.9%;
    --foreground: 210 40% 98%;
    /* ... ダークモードの色定義 ... */
  }
}

2. コンポーネントの拡張

// components/ui/button-extended.tsx
import { Button, ButtonProps } from "@/components/ui/button"
import { Loader2 } from "lucide-react"

interface ExtendedButtonProps extends ButtonProps {
  loading?: boolean
}

export function ButtonExtended({ 
  loading, 
  children, 
  disabled,
  ...props 
}: ExtendedButtonProps) {
  return (
    <Button disabled={disabled || loading} {...props}>
      {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
      {children}
    </Button>
  )
}

3. 複合コンポーネントの作成

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

interface StatCardProps {
  title: string
  value: string | number
  description?: string
  trend?: "up" | "down" | "neutral"
  change?: string
}

export function StatCard({ title, value, description, trend, change }: StatCardProps) {
  const trendColors = {
    up: "text-green-600",
    down: "text-red-600",
    neutral: "text-gray-600"
  }

  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
        <CardTitle className="text-sm font-medium">{title}</CardTitle>
        {trend && change && (
          <Badge variant="secondary" className={trendColors[trend]}>
            {trend === "up" ? "↑" : trend === "down" ? "↓" : "→"} {change}
          </Badge>
        )}
      </CardHeader>
      <CardContent>
        <div className="text-2xl font-bold">{value}</div>
        {description && (
          <CardDescription className="text-xs">{description}</CardDescription>
        )}
      </CardContent>
    </Card>
  )
}

アニメーション統合

graph LR
    A[shadcn/ui] --> B[Framer Motion]
    A --> C[CSS Transitions]
    A --> D[Radix UI Animation]
    B --> E[複雑なアニメーション]
    C --> F[シンプルな遷移]
    D --> G[組み込みアニメーション]

Framer Motionとの統合

import { motion } from "framer-motion"
import { Card } from "@/components/ui/card"

export function AnimatedCard({ children }: { children: React.ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.3 }}
      whileHover={{ scale: 1.05 }}
      whileTap={{ scale: 0.95 }}
    >
      <Card>{children}</Card>
    </motion.div>
  )
}

ベストプラクティス

1. ディレクトリ構造

components/
├── ui/              # shadcn/uiコンポーネント
│   ├── button.tsx
│   ├── card.tsx
│   └── ...
├── composite/       # 複合コンポーネント
│   ├── stat-card.tsx
│   └── data-table.tsx
└── layout/          # レイアウトコンポーネント
    ├── header.tsx
    └── sidebar.tsx

2. 型定義の管理

// types/ui.ts
import { VariantProps } from "class-variance-authority"
import { buttonVariants } from "@/components/ui/button"

export type ButtonVariants = VariantProps<typeof buttonVariants>

3. ユーティリティ関数

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

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

// 使用例
<div className={cn(
  "rounded-lg border p-3",
  isActive && "border-primary",
  className
)} />

パフォーマンス最適化

1. 動的インポート

import dynamic from 'next/dynamic'

const DataTable = dynamic(
  () => import('@/components/composite/data-table').then(mod => mod.DataTable),
  { 
    loading: () => <TableSkeleton />,
    ssr: false 
  }
)

2. メモ化

import { memo } from 'react'

export const ExpensiveComponent = memo(({ data }: { data: any[] }) => {
  // 重い処理
  return <div>{/* ... */}</div>
}, (prevProps, nextProps) => {
  return prevProps.data.length === nextProps.data.length
})

トラブルシューティング

よくある問題と解決策

  1. スタイルが適用されない

    • Tailwind設定でコンポーネントディレクトリが含まれているか確認
    • CSS変数が正しく定義されているか確認
  2. TypeScriptエラー

    • @types/react@types/react-domのバージョン確認
    • tsconfig.jsonのpathsが正しく設定されているか確認
  3. ダークモード切り替え

    • next-themesまたは独自の実装でクラス切り替えを実装

まとめ

shadcn/uiは、以下の特徴により現代的なReact開発に最適なソリューションです:

  • 所有権: コードが手元にあるため完全な制御が可能
  • カスタマイズ性: 自由に編集・拡張できる
  • 品質: Radix UIベースの堅牢な実装
  • 開発体験: CLIによる簡単な導入と管理

プロジェクトの要件に合わせて自由にカスタマイズし、独自のデザインシステムを構築できる点が最大の魅力です。