サイトロゴ
Tailwind CSS Created: 2026/05/07 Updated: 2026/05/23

Tailwind CSSのコンポーネント設計パターン集:再利用しやすいUIの作り方

Tailwind CSSで再利用しやすいコンポーネントを設計するためのパターンを紹介します。カード、ボタン、コールアウト、記事UIなどを例に、クラス管理とデザイン統一の考え方を整理します。

シリーズ:Tailwind CSS v4 入門

1

v3→v4 変更点まとめ

2

TS + Next.js / Bun セットアップ

3

@theme でデザイントークン管理

4

レスポンシブ・ダークモード実践

5

動的クラスと @source inline()

6

コンポーネント設計パターン集

ここまでの5回で、Tailwind CSS v4 の基礎から応用まで一通り学んできました。最終回は「手を動かして作る」ことに集中します。

実際のプロダクトで頻繁に登場する6つのコンポーネントを、 設計の考え方・実際のコード・ライブプレビューの3セット で解説します。どのコンポーネントも、これまでのシリーズで学んだ知識を組み合わせて作られています。

🎯 この記事のゴール

Button・Badge・Card・Form・Alert・Layout の6パターンを実装し、「Tailwind v4 でコンポーネントを設計するときの考え方」を体系的に身につけます。各パターンはそのまま自分のプロジェクトにコピー&ペーストして使えます。

実装前に:Tailwind コンポーネント設計の4原則

コンポーネントを作る前に、Tailwind らしい設計の考え方を整理しておきます。

PRINCIPLE 01

バリアントは完全クラス名で管理

第5回で学んだ通り、`bg-${color}` のような文字列結合は避ける。バリアントごとのクラスセットを定数オブジェクトで管理し、TypeScript で型補完を活かす。

PRINCIPLE 02

@theme のトークンを使い切る

第3回で定義したブランドカラー・スペーシング・角丸などのトークンをコンポーネントで積極的に使う。ハードコードした色値を書かない。

PRINCIPLE 03

ベースクラスとバリアントを分離

「すべてのバリアントで共通するクラス」と「バリアントごとに変わるクラス」を分けて管理する。コードが読みやすくなり、バグが減る。

PRINCIPLE 04

dark: はトークンで吸収する

第4回で学んだセマンティックトークン方式を使う。コンポーネントの中にdark: を散らばらせず、.dark ブロックのトークン上書きで一括管理する。

今回実装する6パターン

🔘

Button

バリアント・サイズ・状態

🏷

Badge

ステータス表示

🃏

Card

画像・本文・フッター

📋

Form

入力・バリデーション

🔔

Alert

通知・エラー表示

🗂

Layout

ナビ・サイドバー・本文

Pattern 01 ── Button

最も使用頻度の高いコンポーネントです。バリアント(見た目の種類)・サイズ・ローディング状態の3軸を TypeScript で型安全に管理します。

▶ プレビュー:Button バリアント

Primary

Secondary

Danger

Ghost

▶ プレビュー:Button サイズ

Small

Medium

Large

🔍️

🔘

Button.tsx ── バリアント・サイズ・disabled・loading 状態を一括管理

tsx
// ① バリアントとサイズを定数オブジェクトで管理(文字列結合は使わない)
const variantClasses = {
  primary:   "bg-blue-500 hover:bg-blue-700 text-white shadow-sm",
  secondary: "bg-gray-100 hover:bg-gray-200 text-gray-900 border border-gray-300",
  danger:    "bg-red-500 hover:bg-red-700 text-white shadow-sm",
  ghost:     "bg-transparent hover:bg-blue-50 text-blue-700 border border-blue-300",
} as const;

const sizeClasses = {
  sm: "px-3 py-1.5 text-sm rounded-md gap-1.5",
  md: "px-4 py-2 text-base rounded-lg gap-2",
  lg: "px-6 py-3 text-lg rounded-xl gap-2.5",
} as const;

// ② Props の型を定数から自動生成する
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?:  keyof typeof variantClasses;
  size?:     keyof typeof sizeClasses;
  loading?:  boolean;
  icon?:     React.ReactNode;
  iconOnly?: boolean;
}

export function Button({
  variant = "primary",
  size    = "md",
  loading = false,
  icon,
  iconOnly = false,
  children,
  disabled,
  className = "",
  ...props
}: ButtonProps) {
  // ③ ベースクラスとバリアントを明確に分離する
  const base = "inline-flex items-center justify-center font-medium transition-colors"
            + " focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
            + " disabled:opacity-50 disabled:pointer-events-none";

  const iconOnlyPadding = { sm: "p-1.5", md: "p-2", lg: "p-3" };

  return (
    <button
      className={[
        base,
        variantClasses[variant],
        iconOnly ? iconOnlyPadding[size] : sizeClasses[size],
        className,
      ].join(" ")}
      disabled={disabled || loading}
      aria-busy={loading}
      {...props}
    >
      {loading ? (
        <span className="animate-spin" aria-hidden>⟳</span>
      ) : (
        icon && <span aria-hidden>{icon}</span>
      )}
      {!iconOnly && <span>{children}</span>}
    </button>
  );
}

✨ className を props で受け取る理由

className を props として受け取ることで、呼び出し元から追加のスタイル(例:w-full mt-4 )を渡せます。ただし渡されたクラスが内部クラスと競合する場合はtailwind-merge ライブラリの使用を検討してください。

Pattern 02 ── Badge

ステータス・カテゴリ・タグなどを表示する小さなラベルです。第5回の@source inline() が活きるコンポーネントです。

▶ プレビュー:BADGE バリアント

完了

注意

エラー

注意

未処理

🏷

Badge.tsx ── ステータスに応じた色をセマンティックに管理する

css
@import "tailwindcss";

/* Badge で使う全カラーパターンを明示的に出力 */
@source inline("bg-{green,yellow,red,blue,slate}-100");

@source inline("text-{green,yellow,red,blue,slate}-700");

@source inline("border-{green,yellow,red,blue,slate}-200");
tsx
type BadgeVariant = "success" | "warning" | "error" | "info" | "neutral";

// 各バリアントの完全なクラスセットを定義
const variantClasses: Record<BadgeVariant, string> = {
  success: "bg-green-100 text-green-700 border-green-200",
  warning: "bg-yellow-100 text-yellow-700 border-yellow-200",
  error:   "bg-red-100 text-red-700 border-red-200",
  info:    "bg-blue-100 text-blue-700 border-blue-200",
  neutral: "bg-slate-100 text-slate-700 border-slate-200",
};

const dotColor: Record<BadgeVariant, string> = {
  success: "bg-green-500",
  warning: "bg-yellow-500",
  error:   "bg-red-500",
  info:    "bg-blue-500",
  neutral: "bg-slate-500",
};

interface BadgeProps {
  variant:  BadgeVariant;
  label:    string;
  dot?:     boolean;
}

export function Badge({ variant, label, dot = true }: BadgeProps) {
  return (
    <span className={`
      inline-flex items-center gap-1.5 rounded-full
      px-2.5 py-0.5 text-xs font-semibold border
      ${variantClasses[variant]}
    `}>
      {dot && (
        <span className={`w-1.5 h-1.5 rounded-full ${dotColor[variant]}`} />
      )}
      {label}
    </span>
  );
}

Pattern 03 ── Card

画像・ヘッダー・本文・フッターで構成されるカードです。複合コンポーネントパターンを使い、柔軟に組み合わせられる構造にします。

▶ プレビュー:CARD

🎨

デザイン

Tailwind v4 入門

CSS-First な設計で開発体験が大きく変わります。

2025/01

詳細 →

パフォーマンス

高速ビルド

Rust 製エンジンで差分ビルドが 100 倍速くなります。

2025/02

詳細 →

🌙

UX

ダークモード

設定なしで dark: プレフィックスが使えます。

2025/03

詳細 →

🃏

Card.tsx ── 複合コンポーネントパターンで柔軟に組み合わせる

tsx
// 複合コンポーネントパターン:
// <Card>, <Card.Image>, <Card.Body>, <Card.Footer> をそれぞれ定義し
// 呼び出し元で自由に組み合わせられるようにする

function CardRoot({ children, className = "" }: {
  children: React.ReactNode; className?: string;
}) {
  return (
    <div className={`
      rounded-card overflow-hidden border
      bg-white border-gray-200
      dark:bg-slate-800 dark:border-slate-700
      shadow-card transition-shadow hover:shadow-dropdown
      ${className}
    `}>
      {children}
    </div>
  );
}

function CardImage({ src, alt, fallbackEmoji = "🖼" }: {
  src?: string; alt?: string; fallbackEmoji?: string;
}) {
  if (!src) {
    return (
      <div className="h-40 bg-blue-50 flex items-center justify-center text-4xl">
        {fallbackEmoji}
      </div>
    );
  }
  return <img src={src} alt={alt ?? ""} className="w-full h-40 object-cover" />;
}

function CardBody({ children, className = "" }: {
  children: React.ReactNode; className?: string;
}) {
  return (
    <div className={`p-5 space-y-2 ${className}`}>{children}</div>
  );
}

function CardFooter({ children, className = "" }: {
  children: React.ReactNode; className?: string;
}) {
  return (
    <div className={`
      px-5 py-3 border-t flex items-center justify-between
      border-gray-100 dark:border-slate-700
      text-sm text-gray-500 dark:text-slate-400
      ${className}
    `}>
      {children}
    </div>
  );
}

// Card に子コンポーネントをぶら下げてエクスポート
export const Card = Object.assign(CardRoot, {
  Image:  CardImage,
  Body:   CardBody,
  Footer: CardFooter,
});
css
import { Card } from "./Card";

function ArticleCard({ post }: { post: Post }) {
  return (
    <Card>
      <Card.Image src={post.thumbnail} alt={post.title} />
      <Card.Body>
        <span className="text-xs font-bold text-blue-600">{post.category}</span>
        <h3 className="font-bold text-gray-900 dark:text-white">{post.title}</h3>
        <p className="text-sm text-gray-500 dark:text-slate-400">{post.excerpt}</p>
      </Card.Body>
      <Card.Footer>
        <span>{post.date}</span>
        <a href={post.url} className="text-blue-500 hover:text-blue-700 font-medium">
          詳細 →
        </a>
      </Card.Footer>
    </Card>
  );
}

Pattern 04 ── Form(入力フィールド)

ラベル・入力欄・バリデーションメッセージをセットで管理します。フォーカス時・エラー時のスタイルをfocus-visible: で適切に制御します。

▶ プレビュー:FORMフィールド

メールアドレス *

you@example.com

ログインに使用するメールアドレスを入力してください

パスワード *

●●●●

⚠️ パスワードは8文字以上で入力してください

プラン

無料プラン

📋

FormField.tsx ── ラベル・入力・エラーを一体管理する Field コンポーネント

tsx
import { useId } from "react";

interface FormFieldProps {
  label:       string;
  required?:   boolean;
  error?:      string;   // エラーメッセージ。存在するとエラースタイルに
  hint?:       string;   // 入力補助テキスト
  children:    React.ReactElement<{ id?: string; "aria-describedby"?: string; "aria-invalid"?: boolean }>;
}

export function FormField({ label, required, error, hint, children }: FormFieldProps) {
  const id      = useId();
  const hintId  = `${id}-hint`;
  const errorId = `${id}-error`;

  return (
    <div className="flex flex-col gap-1.5">

      {/* ラベル */}
      <label htmlFor={id} className="text-sm font-semibold text-gray-800 dark:text-slate-200">
        {label}
        {required && <span className="text-red-500 ml-0.5" aria-hidden>*</span>}
      </label>

      {/* input / select などを受け取り、id と aria 属性を自動で渡す */}
      {React.cloneElement(children, {
        id,
        "aria-describedby": error ? errorId : hint ? hintId : undefined,
        "aria-invalid": !!error,
      })}

      {/* ヒント(エラーがないとき表示)*/}
      {hint && !error && (
        <p id={hintId} className="text-xs text-gray-500 dark:text-slate-400">{hint}</p>
      )}

      {/* エラーメッセージ */}
      {error && (
        <p id={errorId} role="alert" className="text-xs text-red-600 dark:text-red-400 font-medium">
          ⚠ {error}
        </p>
      )}
    </div>
  );
}

// 共通スタイルを持つ Input コンポーネント
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  error?: boolean;
}

export function Input({ error, className = "", ...props }: InputProps) {
  return (
    <input
      className={`
        w-full rounded-button px-3 py-2 text-sm
        border bg-white text-gray-900
        placeholder:text-gray-400
        focus-visible:outline-none focus-visible:ring-2
        dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500
        transition-colors
        ${error
          ? "border-red-400 focus-visible:ring-red-400 dark:border-red-500"
          : "border-gray-300 focus-visible:ring-blue-500 dark:border-slate-600"
        }
        ${className}
      `}
      {...props}
    />
  );
}

Pattern 05 ── Alert(通知・エラー表示)

情報・成功・警告・エラーの4種類を統一インターフェースで扱います。アイコンと本文の配置、閉じるボタンを含む実用的な実装です。

▶ プレビュー:ALERT バリアント

ℹ️

情報

新しいバージョンが利用可能です。

成功

プロフィールを更新しました。

⚠️

警告

ストレージの使用量が 80% に達しています。

🚫

エラー

ファイルのアップロードに失敗しました。

🔔

Alert.tsx ── 4種類のバリアントと閉じるボタンを実装する

tsx
"use client";
import { useState } from "react";

type AlertVariant = "info" | "success" | "warning" | "error";

const variantConfig: Record<AlertVariant, {
  wrapper: string;
  title:   string;
  icon:    string;
}> = {
  info: {
    wrapper: "bg-blue-50 border-blue-200 dark:bg-blue-950/40 dark:border-blue-800",
    title:   "text-blue-800 dark:text-blue-300",
    icon:    "ℹ️",
  },
  success: {
    wrapper: "bg-green-50 border-green-200 dark:bg-green-950/40 dark:border-green-800",
    title:   "text-green-800 dark:text-green-300",
    icon:    "✅",
  },
  warning: {
    wrapper: "bg-yellow-50 border-yellow-200 dark:bg-yellow-950/40 dark:border-yellow-800",
    title:   "text-yellow-800 dark:text-yellow-300",
    icon:    "⚠️",
  },
  error: {
    wrapper: "bg-red-50 border-red-200 dark:bg-red-950/40 dark:border-red-800",
    title:   "text-red-800 dark:text-red-300",
    icon:    "🚫",
  },
};

interface AlertProps {
  variant:     AlertVariant;
  title:       string;
  description?: string;
  closable?:   boolean;
}

export function Alert({ variant, title, description, closable = false }: AlertProps) {
  const [visible, setVisible] = useState(true);
  const cfg = variantConfig[variant];

  if (!visible) return null;

  return (
    <div
      role="alert"
      aria-live="polite"
      className={`
        flex items-start gap-3 rounded-lg border p-4
        ${cfg.wrapper}
      `}
    >
      <span className="text-xl flex-shrink-0 mt-0.5" aria-hidden>{cfg.icon}</span>

      <div className="flex-1 space-y-1">
        <p className={`text-sm font-semibold ${cfg.title}`}>{title}</p>
        {description && (
          <p className="text-sm text-gray-600 dark:text-slate-400">{description}</p>
        )}
      </div>

      {closable && (
        <button
          onClick={() => setVisible(false)}
          className="flex-shrink-0 rounded p-0.5 opacity-60 hover:opacity-100
                     focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current"
          aria-label="閉じる"
        >
          ✕
        </button>
      )}
    </div>
  );
}

Pattern 06 ── Layout(ページ全体の骨格)

ナビゲーション・サイドバー・メインコンテンツ・フッターを組み合わせたページレイアウトです。レスポンシブ対応とダークモードを両立させます。

▶ プレビュー:Layout(縮小版)

MyApp

ダッシュボード

プロジェクト

設定

ダッシュボード

売上

¥1,240,000

ユーザー

3,842人

注文

128件

© 2025 MyApp — Tailwind CSS v4

🗂

AppLayout.tsx ── ナビ・サイドバー・本文を組み合わせたベースレイアウト

tsx
"use client";
import { useState } from "react";

interface AppLayoutProps {
  children:  React.ReactNode;
  sidebar?:  React.ReactNode;
  title?:    string;
}

export function AppLayout({ children, sidebar, title }: AppLayoutProps) {
  const [sidebarOpen, setSidebarOpen] = useState(false);

  return (
    /* ① 全体ラッパー:min-h-screen で縦方向に画面全体を使う */
    <div className="min-h-screen bg-gray-50 dark:bg-slate-950 flex flex-col">

      {/* ② ナビゲーション:sticky で上部に固定 */}
      <header className="
        sticky top-0 z-40 h-header
        bg-white/90 dark:bg-slate-900/90 backdrop-blur-sm
        border-b border-gray-200 dark:border-slate-700
        flex items-center justify-between px-4 md:px-6
      ">
        <span className="font-bold text-blue-600">MyApp</span>
        {/* モバイル:ハンバーガーボタン */}
        <button
          className="md:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-800"
          onClick={() => setSidebarOpen(o => !o)}
          aria-label="メニューを開く"
        >☰</button>
      </header>

      {/* ③ 本文領域:サイドバー + メインのフレックスレイアウト */}
      <div className="flex flex-1">

        {/* ④ サイドバー:モバイルで非表示 / PC で固定表示 */}
        {sidebar && (
          <>
            {{/* モバイル用オーバーレイ */}}
            {sidebarOpen && (
              <div
                className="fixed inset-0 bg-black/40 z-30 md:hidden"
                onClick={() => setSidebarOpen(false)}
              />
            )}
            <aside className={`
              fixed top-header left-0 bottom-0 z-30
              w-sidebar bg-white dark:bg-slate-900
              border-r border-gray-200 dark:border-slate-700
              overflow-y-auto transition-transform
              md:translate-x-0 md:sticky md:top-header md:h-[calc(100vh-var(--spacing-header))]
              ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}
            `}>
              {sidebar}
            </aside>
          </>
        )}

        {/* ⑤ メインコンテンツ */}
        <main className="flex-1 min-w-0 p-4 md:p-8">
          {title && (
            <h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-6">
              {title}
            </h1>
          )}
          {children}
        </main>
      </div>

      {/* ⑥ フッター */}
      <footer className="border-t border-gray-200 dark:border-slate-800 py-4 px-6
                        text-sm text-gray-500 dark:text-slate-500 text-center">
        © 2025 MyApp
      </footer>

    </div>
  );
}

✨ @theme のトークンがレイアウトで活きる

第3回で定義した--spacing-header (ヘッダーの高さ)と--spacing-sidebar (サイドバー幅)がh-header w-sidebar top-header クラスとして使えています。レイアウト全体の寸法を1箇所で管理できるため、後からヘッダーの高さを変えたいときも@theme の1行を書き換えるだけで済みます。

6パターンを通じた設計の振り返り

6つのコンポーネントを通じて、共通するパターンが浮かび上がります。

コンポーネント バリアント管理 @theme 活用 ダークモード アクセシビリティ
Button 定数オブジェクトrounded-button dark:focus-visible: 、aria-busy
Badge 定数オブジェクト + @source inline() セマンティックカラー dark: 意味のある色の使用
Card 複合コンポーネントrounded-button 、shadow-card dark: 画像の alt 属性
Form error フラグで分岐rounded-button dark: useId、aria-describedby
Alert 定数オブジェクト セマンティックカラー dark: role="alert"、aria-live
Layout h-header w-sidebar dark: aria-label、landmark

第6回のまとめ

今回学んだこと

  • バリアントはas const 付きの定数オブジェクトで管理し、keyof typeof で型を導出するのが TypeScript × Tailwind の基本パターン
  • 複合コンポーネントパターン(Card.ImageCard.Bodyなど)を使うと、呼び出し元で柔軟に組み合わせられる構造になる
  • Form のFormField useId() で ID を生成し、aria-describedby aria-invalid を自動設定することでアクセシビリティを担保できる
  • Layout でのh-header w-sidebar クラスは@theme トークンから生成される。サイズを変えたいときは CSS 1行の変更で全体に反映できる
  • dark: は個別コンポーネントに分散させず、セマンティックトークン(.dark ブロックの変数上書き)で一括管理するとメンテナンスコストが下がる

🎉

シリーズ完結、おめでとうございます!

第1回の「v4 の変更点理解」から、セットアップ・デザイントークン・レスポンシブ・ダークモード・動的クラスの罠・コンポーネント設計まで、Tailwind CSS v4 を実践で使いこなすための知識をすべて学びました。あとは手を動かすだけです。

01. v3→v4 変更点

02. セットアップ

03. @theme トークン

04. レスポンシブ・ダーク

05. @source inline()

06. コンポーネント設計