概要
Tailwind CSS でテーブル UI を実装する。ヘッダー固定・行ホバー・ストライプ・ソートインジケーター・空状態・ローディングスケルトン・レスポンシブスクロールのパターンをまとめる。
インストール
npm install tailwindcss
実装
基本テーブル(ヘッダー・行ホバー・ストライプ)
// components/BasicTable.tsx
type Column<T> = {
key: keyof T;
label: string;
};
type Props<T extends { id: number | string }> = {
columns: Column<T>[];
data: T[];
};
export function BasicTable<T extends { id: number | string }>({
columns,
data,
}: Props<T>) {
return (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
{columns.map((col) => (
<th
key={String(col.key)}
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500"
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{data.map((row, i) => (
<tr
key={row.id}
className={`hover:bg-blue-50 ${i % 2 === 1 ? "bg-gray-50/50" : ""}`}
>
{columns.map((col) => (
<td
key={String(col.key)}
className="whitespace-nowrap px-4 py-3 text-gray-700"
>
{String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
空状態・ローディングスケルトン
// components/DataTable.tsx
type Status = "loading" | "empty" | "data";
type Props = {
columns: { key: string; label: string }[];
data: Record<string, string>[];
status: Status;
};
function SkeletonRow({ cols }: { cols: number }) {
return (
<tr>
{Array.from({ length: cols }).map((_, i) => (
<td key={i} className="px-4 py-3">
<div className="h-4 animate-pulse rounded bg-gray-200" />
</td>
))}
</tr>
);
}
export function DataTable({ columns, data, status }: Props) {
return (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
{columns.map((col) => (
<th
key={col.key}
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500"
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{status === "loading" &&
Array.from({ length: 3 }).map((_, i) => (
<SkeletonRow key={i} cols={columns.length} />
))}
{status === "empty" && (
<tr>
<td
colSpan={columns.length}
className="px-4 py-12 text-center text-sm text-gray-400"
>
データがありません
</td>
</tr>
)}
{status === "data" &&
data.map((row, i) => (
<tr
key={i}
className="hover:bg-blue-50"
>
{columns.map((col) => (
<td
key={col.key}
className="whitespace-nowrap px-4 py-3 text-gray-700"
>
{row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
ソートインジケーター付きヘッダー
// components/SortableHeader.tsx
type SortDir = "asc" | "desc" | null;
type Props = {
label: string;
sortDir: SortDir;
onClick: () => void;
};
export function SortableHeader({ label, sortDir, onClick }: Props) {
return (
<th
onClick={onClick}
className="cursor-pointer select-none px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-800"
>
<span className="flex items-center gap-1">
{label}
<span className="text-gray-300">
{sortDir === "asc" ? "↑" : sortDir === "desc" ? "↓" : "↕"}
</span>
</span>
</th>
);
}
使用例
// app/page.tsx
import { BasicTable } from "@/components/BasicTable";
type User = { id: number; name: string; email: string; role: string };
const USERS: User[] = [
{ id: 1, name: "山田 太郎", email: "taro@example.com", role: "Admin" },
{ id: 2, name: "鈴木 花子", email: "hanako@example.com", role: "Editor" },
{ id: 3, name: "田中 次郎", email: "jiro@example.com", role: "Viewer" },
];
const COLUMNS = [
{ key: "id" as const, label: "ID" },
{ key: "name" as const, label: "名前" },
{ key: "email" as const, label: "メール" },
{ key: "role" as const, label: "ロール" },
];
export default function Page() {
return (
<main className="mx-auto max-w-3xl p-8">
<h1 className="mb-6 text-2xl font-bold">ユーザー一覧</h1>
<BasicTable columns={COLUMNS} data={USERS} />
</main>
);
}
ポイント
overflow-x-autoを外側divに付けることで、テーブルが横に長い場合にスクロール可能になる。min-w-fullをtableに付けてコンテナ幅まで広げるdivide-y divide-gray-200でヘッダーと本体の境界線、divide-y divide-gray-100で行間の区切りを引く- ストライプは
i % 2 === 1 ? "bg-gray-50/50" : ""で奇数行に薄い背景を付ける。hover:bg-blue-50と組み合わせてもホバーが優先されて見やすい - スケルトンローディングは
animate-pulseクラスだけで実現できる。実際のデータ行と同じ列数・行数にすることでレイアウトシフトを防げる whitespace-nowrapをセルに付けることで長いテキストが改行されない。truncateとmax-w-xsを組み合わせると長いテキストを省略表示できる