T-CREATOR

shadcn/ui のコンポーネント一覧と使い方まとめ

shadcn/ui のコンポーネント一覧と使い方まとめ

React 開発において、美しく使いやすい UI コンポーネントを効率的に実装したいと思ったことはありませんか?shadcn/ui は、そんな開発者の願いを叶えてくれる革新的な UI コンポーネントライブラリです。

従来のコンポーネントライブラリとは異なるアプローチを採用し、開発者が真にカスタマイズ可能で高品質な UI を構築できるよう設計されています。本記事では、shadcn/ui の基本的な特徴から具体的な実装方法まで、初心者の方にもわかりやすく解説いたします。

背景

shadcn/ui とは何か

shadcn/ui は、モダンな React アプリケーション開発のために設計された、copy & paste スタイルの UI コンポーネントライブラリです。従来の npm パッケージとして配布されるライブラリとは異なり、必要なコンポーネントをプロジェクトに直接コピーして使用する革新的なアプローチを採用しています。

このライブラリは、Tailwind CSS と Radix UI を基盤として構築されており、アクセシビリティと美しいデザインの両方を兼ね備えた高品質なコンポーネントを提供します。開発者は、完全にカスタマイズ可能なコードを手に入れることができ、プロジェクトの要件に合わせて自由に調整することが可能です。

shadcn/ui の最大の特徴は、「コンポーネントを所有する」という哲学にあります。外部ライブラリに依存するのではなく、プロジェクト内にコンポーネントのソースコードを持つことで、完全な制御権を開発者に与えているのです。

React UI ライブラリとしての特徴

shadcn/ui は、React UI ライブラリとして以下の特徴を持っています。

まず、TypeScript ファーストの設計により、型安全性が保証されています。すべてのコンポーネントには適切な型定義が含まれており、開発時のエラーを事前に防ぐことができます。また、IntelliSense による自動補完も充実しており、開発効率の向上にも貢献します。

次に、Tailwind CSS との深い統合により、ユーティリティファーストなスタイリングが可能です。プリセットされた美しいデザインを基に、簡単なクラス名の変更だけで見た目をカスタマイズできます。レスポンシブデザインやダークモード対応も、Tailwind CSS の機能を活用して簡単に実現できるでしょう。

さらに、Radix UI をベースとすることで、WCAG ガイドラインに準拠したアクセシビリティ機能が標準で組み込まれています。スクリーンリーダー対応やキーボードナビゲーション、適切な ARIA 属性の設定など、インクルーシブな UI を構築するための基盤が整っています。

mermaidflowchart TD
    A[shadcn/ui] --> B[TypeScript]
    A --> C[Tailwind CSS]
    A --> D[Radix UI]
    B --> E[型安全性]
    C --> F[ユーティリティファースト]
    D --> G[アクセシビリティ]
    E --> H[高品質なUI]
    F --> H
    G --> H

なぜ shadcn/ui が選ばれるのか

shadcn/ui が多くの開発者に選ばれる理由は、その独特なアプローチにあります。

従来のコンポーネントライブラリでは、パッケージとしてインストールされたコンポーネントを使用するため、カスタマイズに限界がありました。また、ライブラリのアップデートによって予期しない変更が発生したり、プロジェクトの要件に合わない制約に直面したりすることも少なくありませんでした。

shadcn/ui は、こうした問題を根本的に解決します。コンポーネントのソースコードがプロジェクト内に存在するため、開発者は必要に応じて自由にカスタマイズできます。バージョン管理も自分のペースで行えるため、安定したプロジェクト運営が可能です。

また、学習コストの低さも大きな魅力です。React の基本的な知識があれば、すぐに使い始めることができます。複雑な設定ファイルや独自の API を覚える必要がなく、標準的な React コンポーネントとして扱えるため、チーム開発でも導入しやすいでしょう。

課題

コンポーネント選択の複雑さ

React 開発において、適切な UI コンポーネントを選択することは、プロジェクトの成功に直結する重要な決定です。しかし、市場には数多くのコンポーネントライブラリが存在し、それぞれ異なる特徴と制約を持っているため、選択は非常に複雑になっています。

多くの開発者が直面する問題の一つは、ライブラリ間の互換性です。異なるライブラリのコンポーネントを組み合わせて使用する際、スタイルの競合や JavaScript の動作の不整合が発生することがあります。また、各ライブラリが独自のテーマシステムを持っているため、統一感のあるデザインを維持することも困難です。

さらに、プロジェクトの要件が変化した際の対応も課題となります。初期段階では適切だったライブラリが、後にプロジェクトの成長とともに制約となってしまうケースも珍しくありません。特に、カスタマイズ性に制限があるライブラリを選択してしまった場合、後から変更することは非常にコストが高くなります。

実装方法がわからない

UI コンポーネントの実装において、多くの初心者開発者が直面する問題は、「どこから始めればよいかわからない」ということです。公式ドキュメントを読んでも、実際のプロジェクトでの使い方が理解できない場合があります。

特に、複雑なコンポーネントの組み合わせやカスタマイズ方法については、ドキュメントだけでは十分な情報が得られないことが多いのです。例えば、フォームコンポーネントとバリデーション機能をどのように組み合わせるか、レスポンシブデザインをどのように実装するかなど、実践的な知識が不足しがちです。

また、TypeScript を使用している場合、型定義の理解も必要になります。プロパティの型や、コンポーネント間でのデータの受け渡し方法など、型安全性を保ちながら開発を進めるためのベストプラクティスを習得する必要があります。

mermaidflowchart LR
    A[開発者] --> B[ドキュメント読む]
    B --> C[実装方法不明]
    C --> D[試行錯誤]
    D --> E[時間浪費]
    E --> F[プロジェクト遅延]

カスタマイズの難しさ

既存の UI コンポーネントライブラリを使用する際、デザイナーの要求やプロジェクトの要件に合わせてカスタマイズすることは、しばしば困難な作業となります。

多くのライブラリでは、限定的なカスタマイズオプションしか提供されておらず、根本的なデザイン変更を行うことができません。CSS のオーバーライドを試みても、ライブラリの内部構造が複雑で、思うような結果が得られないことがあります。また、ライブラリのアップデートによって、カスタマイズした部分が無効になってしまうリスクも存在します。

さらに、テーマシステムの理解も必要になります。ライブラリ固有のテーマ設定方法を学習し、適切に設定する必要があるため、学習コストが高くなってしまいます。チーム開発においては、全メンバーがこれらの知識を共有する必要があり、開発効率の低下につながることもあるでしょう。

解決策

shadcn/ui の導入方法

shadcn/ui の導入は、非常にシンプルで直感的なプロセスです。従来の npm パッケージのインストールとは異なり、専用の CLI ツールを使用してプロジェクトに最適化された形でセットアップを行います。

まず、既存の Next.js プロジェクトまたは新規プロジェクトを準備します。shadcn/ui は、Next.js、Vite、Remix 等の主要な React フレームワークをサポートしています。

初期設定は、以下のコマンドを実行するだけで完了します:

javascriptnpx shadcn-ui@latest init

このコマンドを実行すると、対話形式でプロジェクトの設定を行えます。TypeScript の使用有無、スタイルファイルの配置場所、コンポーネントのインポートパスなど、プロジェクトに最適な設定を選択できます。

設定完了後、components.jsonファイルが生成され、プロジェクト固有の設定が保存されます:

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"
  }
}

この設定により、以降のコンポーネント追加がプロジェクトの構造に従って自動的に最適化されます。

コンポーネントの追加は、必要に応じて個別に行います:

javascriptnpx shadcn-ui@latest add button

このコマンドにより、Button コンポーネントのソースコードがプロジェクトにコピーされ、すぐに使用可能になります。

コンポーネント管理のベストプラクティス

shadcn/ui を効果的に活用するためには、適切なコンポーネント管理戦略が重要です。

まず、コンポーネントの構造化に関するベストプラクティスを確立しましょう。shadcn/ui のコンポーネントは、通常components​/​ui​/​ディレクトリに配置されます。この基本構造を維持しつつ、プロジェクト固有のコンポーネントは別のディレクトリで管理することを推奨します:

graphqlsrc/
├── components/
│   ├── ui/          # shadcn/uiコンポーネント
│   │   ├── button.tsx
│   │   ├── input.tsx
│   │   └── card.tsx
│   ├── forms/       # フォーム関連コンポーネント
│   └── layout/      # レイアウトコンポーネント
└── lib/
    └── utils.ts     # ユーティリティ関数

次に、コンポーネントのカスタマイズ方法について説明します。shadcn/ui のコンポーネントは、Tailwind CSS の Variant API を活用して設計されています。これにより、プロパティベースでスタイルを制御できます:

typescriptimport { Button } from '@/components/ui/button';

export function CustomButton() {
  return (
    <>
      <Button variant='default' size='default'>
        デフォルト
      </Button>
      <Button variant='destructive' size='lg'>
        削除ボタン
      </Button>
      <Button variant='outline' size='sm'>
        アウトライン
      </Button>
    </>
  );
}

バリアントの拡張も簡単に行えます。button.tsx ファイルを直接編集して、新しいバリアントを追加できます:

typescriptconst buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default:
          'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive:
          'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline:
          'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
        // カスタムバリアントを追加
        success:
          'bg-green-500 text-white hover:bg-green-600',
      },
      // サイズバリアントも拡張可能
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        xl: 'h-14 rounded-lg px-12 text-lg',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

効率的な開発フロー

shadcn/ui を使用した効率的な開発フローを確立することで、チーム全体の生産性を向上させることができます。

まず、プロジェクト開始時に必要なコンポーネントセットを特定し、一括で導入することを推奨します。これにより、開発中にコンポーネントが不足して作業が中断されることを防げます:

bash# 基本的なコンポーネントセットを一括導入
npx shadcn-ui@latest add button input card dialog form table

次に、コンポーネントのカスタマイズルールをチーム内で共有します。カスタマイズを行う際は、元のコンポーネントファイルを直接編集するのではなく、必要に応じてラッパーコンポーネントを作成することを推奨します:

typescript// components/custom/branded-button.tsx
import {
  Button,
  ButtonProps,
} from '@/components/ui/button';
import { cn } from '@/lib/utils';

interface BrandedButtonProps extends ButtonProps {
  brand?: 'primary' | 'secondary';
}

export function BrandedButton({
  brand = 'primary',
  className,
  ...props
}: BrandedButtonProps) {
  return (
    <Button
      className={cn(
        brand === 'primary' &&
          'bg-brand-primary hover:bg-brand-primary/90',
        brand === 'secondary' &&
          'bg-brand-secondary hover:bg-brand-secondary/90',
        className
      )}
      {...props}
    />
  );
}

最後に、開発環境の整備も重要です。Storybook 等のコンポーネントカタログツールを活用して、カスタマイズしたコンポーネントの動作確認とドキュメント化を行いましょう:

typescript// stories/BrandedButton.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { BrandedButton } from '@/components/custom/branded-button';

const meta: Meta<typeof BrandedButton> = {
  title: 'Custom/BrandedButton',
  component: BrandedButton,
  parameters: {
    layout: 'centered',
  },
  tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Primary: Story = {
  args: {
    brand: 'primary',
    children: 'プライマリボタン',
  },
};

export const Secondary: Story = {
  args: {
    brand: 'secondary',
    children: 'セカンダリボタン',
  },
};

以下は効率的な開発フローの全体像を示した図です:

mermaidflowchart TD
    A[プロジェクト開始] --> B[shadcn/ui初期設定]
    B --> C[必要コンポーネント特定]
    C --> D[コンポーネント一括導入]
    D --> E[カスタマイズルール策定]
    E --> F[ラッパーコンポーネント作成]
    F --> G[Storybookでドキュメント化]
    G --> H[チーム内共有]
    H --> I[継続的改善]

この開発フローにより、一貫性のある高品質な UI を効率的に構築できるようになります。

具体例

基本コンポーネント

Button

Button コンポーネントは、shadcn/ui の中でも最も基本的で汎用性の高いコンポーネントです。様々なバリアントとサイズが用意されており、プロジェクトのあらゆる場面で活用できます。

まず、基本的な使用方法から見ていきましょう:

typescriptimport { Button } from '@/components/ui/button';

export function ButtonExample() {
  return (
    <div className='flex gap-4'>
      <Button>デフォルト</Button>
      <Button variant='secondary'>セカンダリ</Button>
      <Button variant='destructive'>削除</Button>
      <Button variant='outline'>アウトライン</Button>
      <Button variant='ghost'>ゴースト</Button>
      <Button variant='link'>リンク</Button>
    </div>
  );
}

サイズの調整も簡単に行えます:

typescriptexport function ButtonSizes() {
  return (
    <div className='flex items-center gap-4'>
      <Button size='sm'></Button>
      <Button size='default'>標準</Button>
      <Button size='lg'></Button>
    </div>
  );
}

アイコンとの組み合わせも美しく表現できます:

typescriptimport { Button } from '@/components/ui/button';
import { ChevronRight, Download, Plus } from 'lucide-react';

export function ButtonWithIcons() {
  return (
    <div className='flex gap-4'>
      <Button>
        <Plus className='mr-2 h-4 w-4' />
        追加
      </Button>
      <Button variant='outline'>
        <Download className='mr-2 h-4 w-4' />
        ダウンロード
      </Button>
      <Button variant='ghost'>
        続きを見る
        <ChevronRight className='ml-2 h-4 w-4' />
      </Button>
    </div>
  );
}

ローディング状態の実装も可能です:

typescriptimport { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';

export function ButtonLoading() {
  const [isLoading, setIsLoading] = useState(false);

  const handleClick = async () => {
    setIsLoading(true);
    // API呼び出し等の非同期処理
    await new Promise((resolve) =>
      setTimeout(resolve, 2000)
    );
    setIsLoading(false);
  };

  return (
    <Button onClick={handleClick} disabled={isLoading}>
      {isLoading && (
        <Loader2 className='mr-2 h-4 w-4 animate-spin' />
      )}
      {isLoading ? '処理中...' : '送信'}
    </Button>
  );
}

Input

Input コンポーネントは、フォーム作成において必須の要素です。shadcn/ui の Input は、アクセシビリティと使いやすさを重視した設計となっています。

基本的な使用方法は以下のとおりです:

typescriptimport { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

export function InputExample() {
  return (
    <div className='grid w-full max-w-sm items-center gap-1.5'>
      <Label htmlFor='email'>メールアドレス</Label>
      <Input
        type='email'
        id='email'
        placeholder='your@example.com'
      />
    </div>
  );
}

バリデーションエラーの表示も組み込むことができます:

typescriptimport { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useState } from 'react';

export function InputWithValidation() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  const validateEmail = (value: string) => {
    const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!regex.test(value)) {
      setError('有効なメールアドレスを入力してください');
    } else {
      setError('');
    }
  };

  return (
    <div className='grid w-full max-w-sm items-center gap-1.5'>
      <Label htmlFor='email'>メールアドレス</Label>
      <Input
        type='email'
        id='email'
        placeholder='your@example.com'
        value={email}
        onChange={(e) => {
          setEmail(e.target.value);
          validateEmail(e.target.value);
        }}
        className={error ? 'border-red-500' : ''}
      />
      {error && (
        <p className='text-sm text-red-500'>{error}</p>
      )}
    </div>
  );
}

ファイルアップロード用の Input も簡単に実装できます:

typescriptimport { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

export function FileInput() {
  return (
    <div className='grid w-full max-w-sm items-center gap-1.5'>
      <Label htmlFor='file'>ファイル</Label>
      <Input id='file' type='file' />
    </div>
  );
}

Card

Card コンポーネントは、コンテンツをまとめて表示するための versatile なコンテナです。情報をグループ化し、視覚的に分離することで、ユーザーの理解を助けます。

基本的な Card 構造は以下のとおりです:

typescriptimport {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card';

export function CardExample() {
  return (
    <Card className='w-[350px]'>
      <CardHeader>
        <CardTitle>プロジェクト名</CardTitle>
        <CardDescription>
          プロジェクトの概要説明
        </CardDescription>
      </CardHeader>
      <CardContent>
        <p>ここにカードのメインコンテンツを配置します。</p>
      </CardContent>
    </Card>
  );
}

フッター付きの Card も作成できます:

typescriptimport {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';

export function CardWithFooter() {
  return (
    <Card className='w-[350px]'>
      <CardHeader>
        <CardTitle>商品名</CardTitle>
        <CardDescription>商品の説明文</CardDescription>
      </CardHeader>
      <CardContent>
        <p className='text-2xl font-bold'>¥1,200</p>
      </CardContent>
      <CardFooter>
        <Button className='w-full'>カートに追加</Button>
      </CardFooter>
    </Card>
  );
}

複数の Card を使ったグリッドレイアウトの例:

typescriptexport function CardGrid() {
  const products = [
    {
      id: 1,
      name: '商品A',
      price: 1200,
      description: '商品Aの説明',
    },
    {
      id: 2,
      name: '商品B',
      price: 2400,
      description: '商品Bの説明',
    },
    {
      id: 3,
      name: '商品C',
      price: 800,
      description: '商品Cの説明',
    },
  ];

  return (
    <div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'>
      {products.map((product) => (
        <Card key={product.id}>
          <CardHeader>
            <CardTitle>{product.name}</CardTitle>
            <CardDescription>
              {product.description}
            </CardDescription>
          </CardHeader>
          <CardContent>
            <p className='text-2xl font-bold'>
              ¥{product.price.toLocaleString()}
            </p>
          </CardContent>
          <CardFooter>
            <Button variant='outline' className='w-full'>
              詳細を見る
            </Button>
          </CardFooter>
        </Card>
      ))}
    </div>
  );
}

Dialog

Dialog コンポーネントは、モーダルウィンドウを実装するための強力なツールです。フォームの表示、確認メッセージ、詳細情報の表示など、様々な用途で活用できます。

基本的な Dialog の実装例:

typescriptimport {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';

export function DialogExample() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant='outline'>ダイアログを開く</Button>
      </DialogTrigger>
      <DialogContent className='sm:max-w-[425px]'>
        <DialogHeader>
          <DialogTitle>ダイアログのタイトル</DialogTitle>
          <DialogDescription>
            ここにダイアログの説明文を記載します。
            ユーザーに伝えたい重要な情報を含めてください。
          </DialogDescription>
        </DialogHeader>
        <div className='py-4'>
          <p>ダイアログのメインコンテンツです。</p>
        </div>
      </DialogContent>
    </Dialog>
  );
}

フォーム付き Dialog の実装:

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

export function DialogWithForm() {
  return (
    <Dialog>
      <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='name' className='text-right'>
              名前
            </Label>
            <Input id='name' className='col-span-3' />
          </div>
          <div className='grid grid-cols-4 items-center gap-4'>
            <Label
              htmlFor='description'
              className='text-right'
            >
              説明
            </Label>
            <Input
              id='description'
              className='col-span-3'
            />
          </div>
        </div>
        <DialogFooter>
          <Button type='submit'>作成</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

削除確認ダイアログの実装:

typescriptimport {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useState } from 'react';

export function DeleteConfirmDialog({
  isOpen,
  onClose,
  onConfirm,
  itemName,
}: {
  isOpen: boolean;
  onClose: () => void;
  onConfirm: () => void;
  itemName: string;
}) {
  const [isDeleting, setIsDeleting] = useState(false);

  const handleDelete = async () => {
    setIsDeleting(true);
    await onConfirm();
    setIsDeleting(false);
    onClose();
  };

  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>削除の確認</DialogTitle>
          <DialogDescription>
            「{itemName}」を削除しますか?
            この操作は取り消すことができません。
          </DialogDescription>
        </DialogHeader>
        <DialogFooter>
          <Button variant='outline' onClick={onClose}>
            キャンセル
          </Button>
          <Button
            variant='destructive'
            onClick={handleDelete}
            disabled={isDeleting}
          >
            {isDeleting ? '削除中...' : '削除'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

応用コンポーネント

DataTable

DataTable は、大量のデータを効率的に表示・操作するための高機能なコンポーネントです。ソート、フィルタリング、ページネーション等の機能を統合しています。

まず、基本的な DataTable の実装から始めましょう:

typescriptimport {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';

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

const users: User[] = [
  {
    id: 1,
    name: '田中太郎',
    email: 'tanaka@example.com',
    role: '管理者',
  },
  {
    id: 2,
    name: '佐藤花子',
    email: 'sato@example.com',
    role: '編集者',
  },
  {
    id: 3,
    name: '鈴木一郎',
    email: 'suzuki@example.com',
    role: '閲覧者',
  },
];

export function BasicDataTable() {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>名前</TableHead>
          <TableHead>メールアドレス</TableHead>
          <TableHead>役割</TableHead>
          <TableHead className='text-right'>操作</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>
            <TableCell className='text-right'>
              <Button variant='ghost' size='sm'>
                編集
              </Button>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

ソート機能付きの DataTable 実装:

typescriptimport { useState } from 'react';
import { Button } from '@/components/ui/button';
import { ChevronUp, ChevronDown } from 'lucide-react';

type SortDirection = 'asc' | 'desc' | null;

export function SortableDataTable() {
  const [sortField, setSortField] = useState<
    keyof User | null
  >(null);
  const [sortDirection, setSortDirection] =
    useState<SortDirection>(null);

  const handleSort = (field: keyof User) => {
    if (sortField === field) {
      setSortDirection(
        sortDirection === 'asc'
          ? 'desc'
          : sortDirection === 'desc'
          ? null
          : 'asc'
      );
    } else {
      setSortField(field);
      setSortDirection('asc');
    }
  };

  const sortedUsers = [...users].sort((a, b) => {
    if (!sortField || !sortDirection) return 0;

    const aValue = a[sortField];
    const bValue = b[sortField];

    if (sortDirection === 'asc') {
      return aValue > bValue ? 1 : -1;
    } else {
      return aValue < bValue ? 1 : -1;
    }
  });

  const SortableHeader = ({
    field,
    children,
  }: {
    field: keyof User;
    children: React.ReactNode;
  }) => (
    <TableHead>
      <Button
        variant='ghost'
        className='h-auto p-0 font-semibold'
        onClick={() => handleSort(field)}
      >
        {children}
        {sortField === field && (
          <>
            {sortDirection === 'asc' && (
              <ChevronUp className='ml-1 h-4 w-4' />
            )}
            {sortDirection === 'desc' && (
              <ChevronDown className='ml-1 h-4 w-4' />
            )}
          </>
        )}
      </Button>
    </TableHead>
  );

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <SortableHeader field='name'>名前</SortableHeader>
          <SortableHeader field='email'>
            メールアドレス
          </SortableHeader>
          <SortableHeader field='role'>役割</SortableHeader>
          <TableHead className='text-right'>操作</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {sortedUsers.map((user) => (
          <TableRow key={user.id}>
            <TableCell className='font-medium'>
              {user.name}
            </TableCell>
            <TableCell>{user.email}</TableCell>
            <TableCell>{user.role}</TableCell>
            <TableCell className='text-right'>
              <Button variant='ghost' size='sm'>
                編集
              </Button>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

フィルタリング機能の追加:

typescriptimport { Input } from '@/components/ui/input';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';

export function FilterableDataTable() {
  const [searchTerm, setSearchTerm] = useState('');
  const [roleFilter, setRoleFilter] =
    useState<string>('all');

  const filteredUsers = users.filter((user) => {
    const matchesSearch =
      user.name
        .toLowerCase()
        .includes(searchTerm.toLowerCase()) ||
      user.email
        .toLowerCase()
        .includes(searchTerm.toLowerCase());
    const matchesRole =
      roleFilter === 'all' || user.role === roleFilter;

    return matchesSearch && matchesRole;
  });

  return (
    <div className='space-y-4'>
      <div className='flex gap-4'>
        <Input
          placeholder='名前またはメールで検索...'
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          className='max-w-sm'
        />
        <Select
          value={roleFilter}
          onValueChange={setRoleFilter}
        >
          <SelectTrigger className='max-w-[180px]'>
            <SelectValue placeholder='役割で絞り込み' />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value='all'>すべて</SelectItem>
            <SelectItem value='管理者'>管理者</SelectItem>
            <SelectItem value='編集者'>編集者</SelectItem>
            <SelectItem value='閲覧者'>閲覧者</SelectItem>
          </SelectContent>
        </Select>
      </div>

      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>名前</TableHead>
            <TableHead>メールアドレス</TableHead>
            <TableHead>役割</TableHead>
            <TableHead className='text-right'>
              操作
            </TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {filteredUsers.length > 0 ? (
            filteredUsers.map((user) => (
              <TableRow key={user.id}>
                <TableCell className='font-medium'>
                  {user.name}
                </TableCell>
                <TableCell>{user.email}</TableCell>
                <TableCell>{user.role}</TableCell>
                <TableCell className='text-right'>
                  <Button variant='ghost' size='sm'>
                    編集
                  </Button>
                </TableCell>
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell
                colSpan={4}
                className='text-center py-8'
              >
                検索結果が見つかりませんでした
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  );
}

Form

Form コンポーネントは、React Hook Form と統合されており、バリデーション機能を備えた高機能なフォームを簡単に構築できます。

基本的なフォームの実装:

typescriptimport { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';

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='ユーザー名を入力'
                  {...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>
  );
}

複雑なフォームの実装例:

typescriptimport { Textarea } from '@/components/ui/textarea';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';

const registrationSchema = z
  .object({
    firstName: z.string().min(1, '名前は必須です'),
    lastName: z.string().min(1, '姓は必須です'),
    email: z
      .string()
      .email('有効なメールアドレスを入力してください'),
    password: z
      .string()
      .min(8, 'パスワードは8文字以上で入力してください'),
    confirmPassword: z.string(),
    role: z.string().min(1, '役割を選択してください'),
    bio: z.string().optional(),
    agreeToTerms: z
      .boolean()
      .refine((val) => val === true, {
        message: '利用規約に同意する必要があります',
      }),
  })
  .refine(
    (data) => data.password === data.confirmPassword,
    {
      message: 'パスワードが一致しません',
      path: ['confirmPassword'],
    }
  );

export function RegistrationForm() {
  const form = useForm<z.infer<typeof registrationSchema>>({
    resolver: zodResolver(registrationSchema),
    defaultValues: {
      firstName: '',
      lastName: '',
      email: '',
      password: '',
      confirmPassword: '',
      role: '',
      bio: '',
      agreeToTerms: false,
    },
  });

  function onSubmit(
    values: z.infer<typeof registrationSchema>
  ) {
    console.log(values);
  }

  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        className='space-y-6'
      >
        <div className='grid grid-cols-2 gap-4'>
          <FormField
            control={form.control}
            name='firstName'
            render={({ field }) => (
              <FormItem>
                <FormLabel>名前</FormLabel>
                <FormControl>
                  <Input placeholder='太郎' {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name='lastName'
            render={({ field }) => (
              <FormItem>
                <FormLabel></FormLabel>
                <FormControl>
                  <Input placeholder='田中' {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
        </div>

        <FormField
          control={form.control}
          name='email'
          render={({ field }) => (
            <FormItem>
              <FormLabel>メールアドレス</FormLabel>
              <FormControl>
                <Input
                  type='email'
                  placeholder='your@example.com'
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <div className='grid grid-cols-2 gap-4'>
          <FormField
            control={form.control}
            name='password'
            render={({ field }) => (
              <FormItem>
                <FormLabel>パスワード</FormLabel>
                <FormControl>
                  <Input type='password' {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name='confirmPassword'
            render={({ field }) => (
              <FormItem>
                <FormLabel>パスワード確認</FormLabel>
                <FormControl>
                  <Input type='password' {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
        </div>

        <FormField
          control={form.control}
          name='role'
          render={({ field }) => (
            <FormItem>
              <FormLabel>役割</FormLabel>
              <Select
                onValueChange={field.onChange}
                defaultValue={field.value}
              >
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder='役割を選択してください' />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value='developer'>
                    開発者
                  </SelectItem>
                  <SelectItem value='designer'>
                    デザイナー
                  </SelectItem>
                  <SelectItem value='manager'>
                    マネージャー
                  </SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name='bio'
          render={({ field }) => (
            <FormItem>
              <FormLabel>自己紹介</FormLabel>
              <FormControl>
                <Textarea
                  placeholder='自己紹介を記入してください(任意)'
                  className='resize-none'
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />

        <FormField
          control={form.control}
          name='agreeToTerms'
          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>利用規約に同意します</FormLabel>
                <FormDescription>
                  アカウント作成には利用規約への同意が必要です。
                </FormDescription>
              </div>
            </FormItem>
          )}
        />

        <Button type='submit' className='w-full'>
          アカウント作成
        </Button>
      </form>
    </Form>
  );
}

Chart

Chart コンポーネントは、データの可視化を美しく効果的に行うためのツールです。Recharts ライブラリをベースとしており、様々な種類のグラフを作成できます。

基本的な線グラフの実装:

typescriptimport {
  Line,
  LineChart,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from 'recharts';

const data = [
  { month: '1月', sales: 4000, profit: 2400 },
  { month: '2月', sales: 3000, profit: 1398 },
  { month: '3月', sales: 2000, profit: 9800 },
  { month: '4月', sales: 2780, profit: 3908 },
  { month: '5月', sales: 1890, profit: 4800 },
  { month: '6月', sales: 2390, profit: 3800 },
];

export function LineChartExample() {
  return (
    <ResponsiveContainer width='100%' height={300}>
      <LineChart data={data}>
        <XAxis dataKey='month' />
        <YAxis />
        <Tooltip />
        <Line
          type='monotone'
          dataKey='sales'
          stroke='hsl(var(--primary))'
          strokeWidth={2}
        />
        <Line
          type='monotone'
          dataKey='profit'
          stroke='hsl(var(--secondary))'
          strokeWidth={2}
        />
      </LineChart>
    </ResponsiveContainer>
  );
}

棒グラフの実装:

typescriptimport {
  Bar,
  BarChart,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from 'recharts';

export function BarChartExample() {
  return (
    <ResponsiveContainer width='100%' height={300}>
      <BarChart data={data}>
        <XAxis dataKey='month' />
        <YAxis />
        <Tooltip />
        <Bar dataKey='sales' fill='hsl(var(--primary))' />
      </BarChart>
    </ResponsiveContainer>
  );
}

円グラフの実装:

typescriptimport {
  Cell,
  Pie,
  PieChart,
  ResponsiveContainer,
  Tooltip,
} from 'recharts';

const pieData = [
  {
    name: 'デスクトップ',
    value: 400,
    color: 'hsl(var(--chart-1))',
  },
  {
    name: 'モバイル',
    value: 300,
    color: 'hsl(var(--chart-2))',
  },
  {
    name: 'タブレット',
    value: 200,
    color: 'hsl(var(--chart-3))',
  },
  {
    name: 'その他',
    value: 100,
    color: 'hsl(var(--chart-4))',
  },
];

export function PieChartExample() {
  return (
    <ResponsiveContainer width='100%' height={300}>
      <PieChart>
        <Pie
          data={pieData}
          cx='50%'
          cy='50%'
          innerRadius={60}
          outerRadius={120}
          paddingAngle={5}
          dataKey='value'
        >
          {pieData.map((entry, index) => (
            <Cell
              key={`cell-${index}`}
              fill={entry.color}
            />
          ))}
        </Pie>
        <Tooltip />
      </PieChart>
    </ResponsiveContainer>
  );
}

Calendar

Calendar コンポーネントは、日付選択や予定表示のための多機能なコンポーネントです。イベント管理や日程調整など、様々な用途で活用できます。

基本的なカレンダーの実装:

typescriptimport { Calendar } from '@/components/ui/calendar';
import { useState } from 'react';

export function CalendarExample() {
  const [date, setDate] = useState<Date | undefined>(
    new Date()
  );

  return (
    <Calendar
      mode='single'
      selected={date}
      onSelect={setDate}
      className='rounded-md border'
    />
  );
}

複数日選択可能なカレンダー:

typescriptexport function MultiSelectCalendar() {
  const [dates, setDates] = useState<Date[] | undefined>(
    []
  );

  return (
    <div className='space-y-4'>
      <Calendar
        mode='multiple'
        selected={dates}
        onSelect={setDates}
        className='rounded-md border'
      />
      <div>
        <p>選択された日付:</p>
        <ul>
          {dates?.map((date, index) => (
            <li key={index}>
              {date.toLocaleDateString('ja-JP')}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

範囲選択カレンダーの実装:

typescriptimport { DateRange } from 'react-day-picker';

export function RangeCalendar() {
  const [range, setRange] = useState<
    DateRange | undefined
  >();

  return (
    <div className='space-y-4'>
      <Calendar
        mode='range'
        selected={range}
        onSelect={setRange}
        numberOfMonths={2}
        className='rounded-md border'
      />
      {range?.from && range?.to && (
        <p>
          選択された期間:{' '}
          {range.from.toLocaleDateString('ja-JP')} -{' '}
          {range.to.toLocaleDateString('ja-JP')}
        </p>
      )}
    </div>
  );
}

以下は shadcn/ui コンポーネントの活用パターンを示した図です:

mermaidflowchart TD
    A[shadcn/ui コンポーネント] --> B[基本コンポーネント]
    A --> C[応用コンポーネント]

    B --> D[Button]
    B --> E[Input]
    B --> F[Card]
    B --> G[Dialog]

    C --> H[DataTable]
    C --> I[Form]
    C --> J[Chart]
    C --> K[Calendar]

    D --> L[UI操作]
    E --> M[データ入力]
    F --> N[情報表示]
    G --> O[モーダル表示]

    H --> P[データ管理]
    I --> Q[フォーム処理]
    J --> R[データ可視化]
    K --> S[日付管理]

これらのコンポーネントを組み合わせることで、高品質で一貫性のあるユーザーインターフェースを効率的に構築できます。

まとめ

shadcn/ui は、モダンな React 開発における新しい可能性を切り開いたコンポーネントライブラリです。従来の npm パッケージ方式とは一線を画す copy & paste アプローチにより、開発者は真の意味でコンポーネントを所有し、自由にカスタマイズできるようになりました。

shadcn/ui 活用のポイント

まず、プロジェクト開始時の適切な計画が重要です。必要なコンポーネントを事前に特定し、一括で導入することで、開発中の中断を避けることができます。また、チーム内でのカスタマイズルールを明確にし、一貫性のある開発フローを確立することも大切でしょう。

次に、コンポーネントの特性を理解し、適材適所で活用することです。基本コンポーネント(Button、Input、Card、Dialog)は汎用性が高く、多くの場面で活用できます。一方、応用コンポーネント(DataTable、Form、Chart、Calendar)は特定の用途に特化しており、複雑な機能を短時間で実装できる強力なツールです。

TypeScript と Tailwind CSS の組み合わせにより、型安全性と美しいデザインの両方を実現できることも大きなメリットです。開発効率の向上と保守性の確保を同時に達成できるため、長期的なプロジェクト運営においても安心して採用できます。

カスタマイズの自由度の高さも特筆すべき点です。プロジェクトの要件に応じて、コンポーネントを自由に改変できるため、デザインシステムの統一や独自機能の追加が容易になります。

今後の学習方針

shadcn/ui を効果的に活用するためには、段階的な学習アプローチを推奨します。

初期段階では、基本コンポーネントの使い方をマスターしましょう。Button、Input、Card、Dialog の基本的な使用方法を理解し、簡単な UI を構築できるようになることが第一歩です。

次の段階では、応用コンポーネントの活用方法を学習します。DataTable、Form、Chart、Calendar の実装パターンを理解し、実際のプロジェクトで活用できるレベルに到達することを目指しましょう。

上級段階では、カスタマイズ技術の習得が重要です。バリアントの追加、テーマの調整、独自コンポーネントの作成など、プロジェクト固有の要件に対応できる技術力を身につけることが求められます。

継続的な学習のためには、公式ドキュメントの定期的な確認と、コミュニティでの情報交換が有効です。shadcn/ui は活発に開発が進められており、新しいコンポーネントや機能が定期的に追加されています。

最後に、実際のプロジェクトでの経験を積むことが最も重要です。理論だけでなく、実践を通じて得られる知見が、真の習得につながるでしょう。

shadcn/ui は、React 開発の新しいスタンダードとなる可能性を秘めたツールです。今回の記事で紹介した内容を参考に、ぜひ実際のプロジェクトで活用してみてください。きっと、その利便性と柔軟性に驚かれることでしょう。

関連リンク