# Tailwind Table: React Data Table with Sorting, Search, Row Selection and Pagination

> Free Tailwind table for React: sortable columns, search, status filters, checkbox selection, pagination and phone cards. No table library needed.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/tailwind-table-react-data-table-with-sorting-search-row-selection-and-pagination).

- Page, with a live preview: https://shadcn.exchange/r/weinc/tailwind-table-react-data-table-with-sorting-search-row-selection-and-pagination
- Screenshot: https://shadcn.exchange/shot/weinc/tailwind-table-react-data-table-with-sorting-search-row-selection-and-pagination/light.png
- Registry item: https://we.inc/r/tailwind-table-react-data-table-with-sorting-search-row-selection-and-pagination.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/tailwind-table-react-data-table-with-sorting-search-row-selection-and-pagination.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `components/we-inc/tailwind-table-react-data-table-with-sorting-search-row-selection-and-pagination.tsx`

```tsx
import { useEffect, useMemo, useRef, useState } from "react";

// Demo data (made up). Replace with your own rows; keep the same keys or update COLUMNS.
type Status = "Paid" | "Pending" | "Overdue" | "Refunded";
type Row = { id: string; customer: string; email: string; plan: string; amount: number; status: Status; date: string };

const ROWS: Row[] = [
  { id: "INV-1042", customer: "Marta Okafor", email: "marta@northfield.example", plan: "Studio", amount: 240, status: "Paid", date: "2026-10-02" },
  { id: "INV-1041", customer: "Callum Reyes", email: "callum@tidewater.example", plan: "Starter", amount: 49, status: "Pending", date: "2026-10-01" },
  { id: "INV-1040", customer: "Ines Lindqvist", email: "ines@birchandco.example", plan: "Team", amount: 590, status: "Overdue", date: "2026-09-28" },
  { id: "INV-1039", customer: "Dev Raman", email: "dev@quarrylane.example", plan: "Studio", amount: 240, status: "Paid", date: "2026-09-27" },
  { id: "INV-1038", customer: "Hana Kowalski", email: "hana@pinecrest.example", plan: "Starter", amount: 49, status: "Refunded", date: "2026-09-25" },
  { id: "INV-1037", customer: "Tomasz Ellery", email: "tomasz@saltmarsh.example", plan: "Team", amount: 590, status: "Paid", date: "2026-09-22" },
  { id: "INV-1036", customer: "Priya Achebe", email: "priya@foundry.example", plan: "Studio", amount: 240, status: "Pending", date: "2026-09-20" },
  { id: "INV-1035", customer: "Oscar Muller", email: "oscar@lowmoor.example", plan: "Starter", amount: 49, status: "Paid", date: "2026-09-18" },
  { id: "INV-1034", customer: "Yara Benedetti", email: "yara@ashgrove.example", plan: "Team", amount: 590, status: "Overdue", date: "2026-09-15" },
  { id: "INV-1033", customer: "Felix Adeyemi", email: "felix@kestrel.example", plan: "Studio", amount: 240, status: "Paid", date: "2026-09-12" },
  { id: "INV-1032", customer: "Lena Hartmann", email: "lena@riverbend.example", plan: "Starter", amount: 49, status: "Paid", date: "2026-09-10" },
  { id: "INV-1031", customer: "Samir Castillo", email: "samir@oldmill.example", plan: "Team", amount: 590, status: "Pending", date: "2026-09-07" },
];

type Key = "customer" | "plan" | "amount" | "status" | "date";
const COLUMNS: { key: Key; label: string; align?: "right" }[] = [
  { key: "customer", label: "Customer" },
  { key: "plan", label: "Plan" },
  { key: "status", label: "Status" },
  { key: "date", label: "Issued" },
  { key: "amount", label: "Amount", align: "right" },
];
const STATUSES: ("All" | Status)[] = ["All", "Paid", "Pending", "Overdue", "Refunded"];
const PAGE_SIZE = 6;

const BADGE: Record<Status, string> = {
  Paid: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
  Pending: "bg-amber-50 text-amber-800 ring-amber-600/25",
  Overdue: "bg-rose-50 text-rose-700 ring-rose-600/20",
  Refunded: "bg-slate-100 text-slate-700 ring-slate-500/20",
};
const DOT: Record<Status, string> = { Paid: "bg-emerald-500", Pending: "bg-amber-500", Overdue: "bg-rose-500", Refunded: "bg-slate-400" };

const money = (n: number) => "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2 });
const day = (d: string) => new Date(d + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });

function Badge({ s }: { s: Status }) {
  return (
    <span className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${BADGE[s]}`}>
      <span className={`h-1.5 w-1.5 rounded-full ${DOT[s]}`} aria-hidden="true" />
      {s}
    </span>
  );
}

function Check({ checked, mixed, onChange, label }: { checked: boolean; mixed?: boolean; onChange: () => void; label: string }) {
  const ref = useRef<HTMLInputElement>(null);
  useEffect(() => { if (ref.current) ref.current.indeterminate = !!mixed && !checked; }, [mixed, checked]);
  return (
    <input ref={ref} type="checkbox" checked={checked} onChange={onChange} aria-label={label}
      className="h-4 w-4 cursor-pointer rounded border-slate-300 accent-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2" />
  );
}

function SortIcon({ dir }: { dir: "asc" | "desc" | null }) {
  return (
    <svg viewBox="0 0 12 12" className="h-3 w-3" aria-hidden="true">
      <path d="M6 2 9 5H3z" className={dir === "asc" ? "fill-slate-900" : "fill-slate-300"} />
      <path d="M6 10 3 7h6z" className={dir === "desc" ? "fill-slate-900" : "fill-slate-300"} />
    </svg>
  );
}

export default function DataTable() {
  const [query, setQuery] = useState("");
  const [status, setStatus] = useState<"All" | Status>("All");
  const [sort, setSort] = useState<{ key: Key; dir: "asc" | "desc" }>({ key: "date", dir: "desc" });
  const [page, setPage] = useState(0);
  const [selected, setSelected] = useState<Set<string>>(() => new Set(["INV-1040"]));
  const [dense, setDense] = useState(false);

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    const rows = ROWS.filter((r) =>
      (status === "All" || r.status === status) &&
      (!q || [r.customer, r.email, r.id, r.plan].some((v) => v.toLowerCase().includes(q))));
    const m = sort.dir === "asc" ? 1 : -1;
    return [...rows].sort((a, b) => {
      const x = a[sort.key], y = b[sort.key];
      const d = (typeof x === "number" && typeof y === "number" ? x - y : String(x).localeCompare(String(y))) * m;
      return d || b.date.localeCompare(a.date); // ties: newest first
    });
  }, [query, status, sort]);

  const pages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
  const current = Math.min(page, pages - 1);
  const visible = filtered.slice(current * PAGE_SIZE, current * PAGE_SIZE + PAGE_SIZE);
  const allOnPage = visible.length > 0 && visible.every((r) => selected.has(r.id));
  const someOnPage = visible.some((r) => selected.has(r.id));

  const toggle = (id: string) => setSelected((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
  const togglePage = () => setSelected((s) => {
    const n = new Set(s);
    visible.forEach((r) => (allOnPage ? n.delete(r.id) : n.add(r.id)));
    return n;
  });
  const sortBy = (key: Key) => { setSort((s) => ({ key, dir: s.key === key && s.dir === "asc" ? "desc" : "asc" })); setPage(0); };
  const cell = dense ? "py-2" : "py-3.5";
  const ring = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500";

  return (
    <section className="min-h-[600px] bg-slate-50 px-4 py-10 font-[Inter,ui-sans-serif,system-ui] text-slate-900 sm:px-8">
      <div className="mx-auto max-w-5xl">
        <div className="mb-5 flex flex-wrap items-end justify-between gap-3">
          <div>
            <h2 className="text-xl font-semibold tracking-tight">Invoices</h2>
            <p className="mt-1 text-sm text-slate-600">Demo data. {ROWS.length} invoices from the last 30 days.</p>
          </div>
          <div role="group" aria-label="Row density" className="inline-flex rounded-lg bg-slate-200/70 p-0.5 text-sm">
            {[false, true].map((d) => (
              <button key={String(d)} type="button" aria-pressed={dense === d} onClick={() => setDense(d)}
                className={`min-h-[40px] rounded-md px-3 font-medium transition-colors ${ring} ${dense === d ? "bg-white text-slate-900 shadow-sm" : "text-slate-600 hover:text-slate-900"}`}>
                {d ? "Compact" : "Comfortable"}
              </button>
            ))}
          </div>
        </div>

        <div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04)]">
          {/* Toolbar */}
          <div className="flex flex-col gap-3 border-b border-slate-200 p-3 sm:flex-row sm:items-center sm:justify-between">
            <div className="relative sm:w-72">
              <label htmlFor="dt-search" className="sr-only">Search invoices</label>
              <svg viewBox="0 0 20 20" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 fill-slate-400" aria-hidden="true">
                <path d="M8.5 3a5.5 5.5 0 0 1 4.38 8.83l3.64 3.65-1.06 1.06-3.65-3.64A5.5 5.5 0 1 1 8.5 3Zm0 1.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8Z" />
              </svg>
              <input id="dt-search" type="search" value={query} placeholder="Search name, email or invoice"
                onChange={(e) => { setQuery(e.target.value); setPage(0); }}
                className="h-10 w-full rounded-lg border border-slate-300 bg-white pl-9 pr-3 text-sm placeholder:text-slate-400 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30" />
            </div>
            <div className="-mx-1 flex gap-1 overflow-x-auto px-1" role="group" aria-label="Filter by status">
              {STATUSES.map((s) => (
                <button key={s} type="button" aria-pressed={status === s} onClick={() => { setStatus(s); setPage(0); }}
                  className={`h-10 shrink-0 rounded-lg px-3 text-sm font-medium transition-colors ${ring} ${status === s ? "bg-slate-900 text-white" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900"}`}>
                  {s}
                </button>
              ))}
            </div>
          </div>

          {/* Selection bar */}
          <div aria-live="polite" className={`flex items-center justify-between gap-3 border-b px-4 text-sm transition-colors ${selected.size ? "border-sky-100 bg-sky-50 py-2" : "border-transparent py-0"}`}>
            {selected.size > 0 && (
              <>
                <span className="font-medium text-sky-900">{selected.size} selected</span>
                <div className="flex gap-1">
                  <button type="button" className={`min-h-[40px] rounded-md px-3 font-medium text-sky-900 hover:bg-sky-100 ${ring}`}>Send reminder</button>
                  <button type="button" onClick={() => setSelected(new Set())} className={`min-h-[40px] rounded-md px-3 text-slate-700 hover:bg-sky-100 ${ring}`}>Clear</button>
                </div>
              </>
            )}
          </div>

          {filtered.length === 0 ? (
            <div className="flex flex-col items-center px-6 py-16 text-center">
              <div className="grid h-11 w-11 place-items-center rounded-full bg-slate-100">
                <svg viewBox="0 0 20 20" className="h-5 w-5 fill-slate-500" aria-hidden="true"><path d="M3 4h14v2H3zm2 5h10v2H5zm3 5h4v2H8z" /></svg>
              </div>
              <p className="mt-3 font-medium">No invoices match</p>
              <p className="mt-1 text-sm text-slate-600">Try another name or clear the filters.</p>
              <button type="button" onClick={() => { setQuery(""); setStatus("All"); }}
                className={`mt-4 h-10 rounded-lg border border-slate-300 px-4 text-sm font-medium hover:bg-slate-50 ${ring}`}>Clear filters</button>
            </div>
          ) : (
            <>
              {/* Desktop and tablet: real table with a sticky header */}
              <div className="hidden max-h-[420px] overflow-auto md:block">
                <table className="w-full border-separate border-spacing-0 text-sm">
                  <caption className="sr-only">Invoices, sorted by {sort.key} {sort.dir === "asc" ? "ascending" : "descending"}</caption>
                  <thead>
                    <tr>
                      <th scope="col" className="sticky top-0 z-10 w-12 border-b border-slate-200 bg-slate-50/95 py-3 pl-4 text-left backdrop-blur">
                        <Check checked={allOnPage} mixed={someOnPage} onChange={togglePage} label="Select all rows on this page" />
                      </th>
                      {COLUMNS.map((c) => {
                        const active = sort.key === c.key;
                        return (
                          <th key={c.key} scope="col" aria-sort={active ? (sort.dir === "asc" ? "ascending" : "descending") : "none"}
                            className={`sticky top-0 z-10 border-b border-slate-200 bg-slate-50/95 px-4 py-2 font-medium text-slate-600 backdrop-blur ${c.align === "right" ? "text-right" : "text-left"}`}>
                            <button type="button" onClick={() => sortBy(c.key)}
                              className={`-mx-1.5 inline-flex min-h-[32px] items-center gap-1.5 rounded px-1.5 hover:text-slate-900 ${ring} ${active ? "text-slate-900" : ""}`}>
                              {c.label}<SortIcon dir={active ? sort.dir : null} />
                            </button>
                          </th>
                        );
                      })}
                    </tr>
                  </thead>
                  <tbody>
                    {visible.map((r) => {
                      const on = selected.has(r.id);
                      return (
                        <tr key={r.id} className={`transition-colors ${on ? "bg-sky-50/70" : "hover:bg-slate-50"}`}>
                          <td className={`border-b border-slate-100 pl-4 ${cell}`}><Check checked={on} onChange={() => toggle(r.id)} label={`Select ${r.id}`} /></td>
                          <td className={`border-b border-slate-100 px-4 ${cell}`}>
                            <div className="font-medium text-slate-900">{r.customer}</div>
                            {!dense && <div className="text-xs text-slate-600">{r.email}</div>}
                          </td>
                          <td className={`border-b border-slate-100 px-4 text-slate-700 ${cell}`}>{r.plan}</td>
                          <td className={`border-b border-slate-100 px-4 ${cell}`}><Badge s={r.status} /></td>
                          <td className={`whitespace-nowrap border-b border-slate-100 px-4 text-slate-700 ${cell}`}>{day(r.date)}</td>
                          <td className={`border-b border-slate-100 px-4 text-right font-medium tabular-nums ${cell}`}>{money(r.amount)}</td>
                        </tr>
                      );
                    })}
                  </tbody>
                </table>
              </div>

              {/* Phone: each row becomes a card */}
              <div className="md:hidden">
                <div className="flex items-center gap-3 border-b border-slate-100 px-4 py-2 text-sm text-slate-600">
                  <Check checked={allOnPage} mixed={someOnPage} onChange={togglePage} label="Select all rows on this page" />
                  <label htmlFor="dt-sort" className="sr-only">Sort by</label>
                  <select id="dt-sort" value={`${sort.key}:${sort.dir}`}
                    onChange={(e) => { const [key, dir] = e.target.value.split(":"); setSort({ key: key as Key, dir: dir as "asc" | "desc" }); setPage(0); }}
                    className={`ml-auto h-10 rounded-lg border border-slate-300 bg-white px-2 text-sm text-slate-900 ${ring}`}>
                    <option value="date:desc">Newest first</option>
                    <option value="date:asc">Oldest first</option>
                    <option value="amount:desc">Amount, high to low</option>
                    <option value="amount:asc">Amount, low to high</option>
                    <option value="customer:asc">Customer A to Z</option>
                  </select>
                </div>
                <ul>
                  {visible.map((r) => {
                    const on = selected.has(r.id);
                    return (
                      <li key={r.id} className={`flex gap-3 border-b border-slate-100 px-4 ${dense ? "py-2.5" : "py-4"} ${on ? "bg-sky-50/70" : ""}`}>
                        <div className="pt-0.5"><Check checked={on} onChange={() => toggle(r.id)} label={`Select ${r.id}`} /></div>
                        <div className="min-w-0 flex-1">
                          <div className="flex items-start justify-between gap-2">
                            <span className="truncate font-medium">{r.customer}</span>
                            <span className="font-medium tabular-nums">{money(r.amount)}</span>
                          </div>
                          <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-600">
                            <Badge s={r.status} /><span>{r.plan}</span><span aria-hidden="true">·</span><span>{day(r.date)}</span>
                          </div>
                        </div>
                      </li>
                    );
                  })}
                </ul>
              </div>

              {/* Pagination */}
              <nav aria-label="Pagination" className="flex border-t border-slate-200 items-center justify-between gap-3 px-4 py-3 text-sm">
                <p className="text-slate-600">
                  <span className="font-medium text-slate-900">{current * PAGE_SIZE + 1}</span>–<span className="font-medium text-slate-900">{current * PAGE_SIZE + visible.length}</span> of {filtered.length}
                </p>
                <div className="flex items-center gap-1">
                  <button type="button" disabled={current === 0} onClick={() => setPage(current - 1)}
                    className={`h-10 rounded-lg border border-slate-300 px-3 font-medium hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 ${ring}`}>Previous</button>
                  {Array.from({ length: pages }, (_, i) => (
                    <button key={i} type="button" onClick={() => setPage(i)} aria-current={i === current ? "page" : undefined} aria-label={`Page ${i + 1}`}
                      className={`hidden h-10 w-10 rounded-lg font-medium sm:block ${ring} ${i === current ? "bg-slate-900 text-white" : "text-slate-700 hover:bg-slate-100"}`}>{i + 1}</button>
                  ))}
                  <button type="button" disabled={current >= pages - 1} onClick={() => setPage(current + 1)}
                    className={`h-10 rounded-lg border border-slate-300 px-3 font-medium hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 ${ring}`}>Next</button>
                </div>
              </nav>
            </>
          )}
        </div>
      </div>
    </section>
  );
}
```
