レビュー待ち·難易度: 初級·更新: 2026-04-18

Tailwind CSS でテーブル UI をスタイリングする

Tailwind CSS でレスポンシブ対応のテーブル UI を実装し、ヘッダー固定・行ホバー・ストライプ・空状態・ローディング状態のスタイルパターンをまとめた例。

nextjsstylingui-componenttailwindcss

対応バージョン

nextjs 15react 19tailwindcss 4

前提環境

Tailwind CSS の基本クラスを理解していること

概要

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-fulltable に付けてコンテナ幅まで広げる
  • 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 をセルに付けることで長いテキストが改行されない。truncatemax-w-xs を組み合わせると長いテキストを省略表示できる

注意点

tailwind-form-ui はフォーム部品スタイリング集。tailwind-badge-component はバッジ単体。tailwind-animation はアニメーション。これはテーブル UI に特化し、行ホバー・ストライプ・空状態・スクロール対応を含む実務的スタイルパターンをまとめる。

関連サンプル