
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 状態を一括管理
// ① バリアントとサイズを定数オブジェクトで管理(文字列結合は使わない)
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 ── ステータスに応じた色をセマンティックに管理する
@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");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 ── 複合コンポーネントパターンで柔軟に組み合わせる
// 複合コンポーネントパターン:
// <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,
});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 コンポーネント
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種類のバリアントと閉じるボタンを実装する
"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 ── ナビ・サイドバー・本文を組み合わせたベースレイアウト
"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.Image・Card.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. コンポーネント設計