Basic Table
Original · freeclean basic data table
byWeb InnoventixReact + Tailwind
tablebasictables
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/table-basic.jsontable-basic.tsx
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement } from "react";
import { motion, useReducedMotion } from "motion/react";
type Status = "Paid" | "Pending" | "Overdue" | "Draft";
type ColumnKey = "invoice" | "client" | "status" | "amount" | "date";
type SortDir = "asc" | "desc";
interface Invoice {
id: string;
invoice: string;
client: string;
email: string;
status: Status;
amount: number;
date: string;
}
interface Column {
key: ColumnKey;
label: string;
align: "left" | "right";
}
const INVOICES: Invoice[] = [
{ id: "in-1042", invoice: "INV-1042", client: "Northwind Traders", email: "billing@northwind.co", status: "Paid", amount: 4820.0, date: "2026-06-28" },
{ id: "in-1041", invoice: "INV-1041", client: "Blue Bottle Studio", email: "accounts@bluebottle.design", status: "Pending", amount: 1290.5, date: "2026-06-24" },
{ id: "in-1040", invoice: "INV-1040", client: "Meridian Health", email: "ap@meridianhealth.org", status: "Overdue", amount: 9600.0, date: "2026-05-30" },
{ id: "in-1039", invoice: "INV-1039", client: "Cedar & Vine Co.", email: "hello@cedarvine.com", status: "Paid", amount: 640.0, date: "2026-06-19" },
{ id: "in-1038", invoice: "INV-1038", client: "Halcyon Robotics", email: "finance@halcyon.ai", status: "Draft", amount: 15250.0, date: "2026-07-02" },
{ id: "in-1037", invoice: "INV-1037", client: "Foxglove Media", email: "pay@foxglove.tv", status: "Pending", amount: 2075.75, date: "2026-06-15" },
{ id: "in-1036", invoice: "INV-1036", client: "Atlas Freight", email: "invoices@atlasfreight.com", status: "Overdue", amount: 3310.0, date: "2026-05-21" },
{ id: "in-1035", invoice: "INV-1035", client: "Willowbrook School", email: "bursar@willowbrook.edu", status: "Paid", amount: 880.0, date: "2026-06-11" },
{ id: "in-1034", invoice: "INV-1034", client: "Sable & Co. Legal", email: "ap@sablelegal.com", status: "Paid", amount: 5400.0, date: "2026-06-06" },
{ id: "in-1033", invoice: "INV-1033", client: "Nimbus Cloudworks", email: "billing@nimbus.io", status: "Draft", amount: 12180.0, date: "2026-07-09" },
];
const COLUMNS: Column[] = [
{ key: "invoice", label: "Invoice", align: "left" },
{ key: "client", label: "Client", align: "left" },
{ key: "status", label: "Status", align: "left" },
{ key: "amount", label: "Amount", align: "right" },
{ key: "date", label: "Issued", align: "left" },
];
const STATUS_ORDER: Status[] = ["Paid", "Pending", "Overdue", "Draft"];
const statusBadge: Record<Status, string> = {
Paid: "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
Pending: "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
Overdue: "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
Draft: "bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-500/10 dark:text-slate-300 dark:ring-slate-400/20",
};
const statusDot: Record<Status, string> = {
Paid: "bg-emerald-500",
Pending: "bg-amber-500",
Overdue: "bg-rose-500",
Draft: "bg-slate-400",
};
const currencyFmt = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
function formatDate(iso: string): string {
const d = new Date(iso + "T00:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function SortGlyph({ state }: { state: SortDir | "none" }): ReactElement {
if (state === "none") {
return (
<svg viewBox="0 0 12 12" aria-hidden="true" className="h-3 w-3 text-slate-400 dark:text-slate-500">
<path d="M6 1.5 3.5 4.5h5L6 1.5Z" fill="currentColor" opacity="0.5" />
<path d="M6 10.5 3.5 7.5h5L6 10.5Z" fill="currentColor" opacity="0.5" />
</svg>
);
}
return (
<svg
viewBox="0 0 12 12"
aria-hidden="true"
className={`h-3 w-3 text-indigo-600 transition-transform dark:text-indigo-400 ${state === "asc" ? "" : "rotate-180"}`}
>
<path d="M6 2 2.5 6.5h7L6 2Z" fill="currentColor" />
</svg>
);
}
export default function BasicTable(): ReactElement {
const reduce = useReducedMotion();
const [query, setQuery] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<Status | "all">("all");
const [sort, setSort] = useState<{ key: ColumnKey; dir: SortDir }>({ key: "date", dir: "desc" });
const [selected, setSelected] = useState<Set<string>>(() => new Set<string>());
const headerCheckRef = useRef<HTMLInputElement>(null);
const rows = useMemo<Invoice[]>(() => {
const q = query.trim().toLowerCase();
const filtered = INVOICES.filter((r) => {
const matchesQuery =
q === "" ||
r.invoice.toLowerCase().includes(q) ||
r.client.toLowerCase().includes(q) ||
r.email.toLowerCase().includes(q);
const matchesStatus = statusFilter === "all" || r.status === statusFilter;
return matchesQuery && matchesStatus;
});
const sorted = [...filtered].sort((a, b) => {
let cmp = 0;
switch (sort.key) {
case "amount":
cmp = a.amount - b.amount;
break;
case "date":
cmp = a.date.localeCompare(b.date);
break;
case "status":
cmp = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status);
break;
default:
cmp = String(a[sort.key]).localeCompare(String(b[sort.key]));
break;
}
return sort.dir === "asc" ? cmp : -cmp;
});
return sorted;
}, [query, statusFilter, sort]);
const visibleIds = rows.map((r) => r.id);
const allSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
const someSelected = visibleIds.some((id) => selected.has(id)) && !allSelected;
const selectedRows = INVOICES.filter((r) => selected.has(r.id));
const selectedTotal = selectedRows.reduce((sum, r) => sum + r.amount, 0);
useEffect(() => {
if (headerCheckRef.current) headerCheckRef.current.indeterminate = someSelected;
}, [someSelected]);
function handleSort(key: ColumnKey): void {
setSort((prev) => (prev.key === key ? { key, dir: prev.dir === "asc" ? "desc" : "asc" } : { key, dir: "asc" }));
}
function toggleAll(): void {
setSelected((prev) => {
const next = new Set(prev);
const everySelected = visibleIds.length > 0 && visibleIds.every((id) => next.has(id));
if (everySelected) {
visibleIds.forEach((id) => next.delete(id));
} else {
visibleIds.forEach((id) => next.add(id));
}
return next;
});
}
function toggleOne(id: string): void {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
const focusRing =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";
return (
<section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes tbltbl-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.82); }
}
.tbltbl-pulse-dot { animation: tbltbl-pulse 1.9s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.tbltbl-pulse-dot { animation: none; }
}
`}</style>
<div className="mx-auto max-w-5xl">
<motion.div
initial={reduce ? false : { opacity: 0, y: 14 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
{/* Header */}
<div className="flex flex-col gap-4 border-b border-slate-200 p-5 sm:flex-row sm:items-end sm:justify-between sm:p-6 dark:border-slate-800">
<div>
<h2 className="text-lg font-semibold tracking-tight text-slate-900 dark:text-white">Invoices</h2>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Billing across active accounts. Sort, filter, and select rows to reconcile.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-slate-400 dark:text-slate-500">
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4">
<path
d="M9 3.5a5.5 5.5 0 1 0 3.4 9.82l3.14 3.13a.75.75 0 1 0 1.06-1.06l-3.13-3.14A5.5 5.5 0 0 0 9 3.5Zm-4 5.5a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z"
fill="currentColor"
/>
</svg>
</span>
<label htmlFor="tbltbl-search" className="sr-only">
Search invoices
</label>
<input
id="tbltbl-search"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search client or number"
className={`w-full rounded-lg border border-slate-300 bg-white py-2 pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-400 sm:w-56 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500 ${focusRing}`}
/>
</div>
<div>
<label htmlFor="tbltbl-status" className="sr-only">
Filter by status
</label>
<select
id="tbltbl-status"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as Status | "all")}
className={`w-full rounded-lg border border-slate-300 bg-white py-2 pl-3 pr-8 text-sm text-slate-700 sm:w-auto dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 ${focusRing}`}
>
<option value="all">All statuses</option>
{STATUS_ORDER.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full min-w-[42rem] border-collapse text-left text-sm">
<caption className="sr-only">
Invoices with number, client, status, amount, and issue date. Column headers are sortable buttons.
</caption>
<thead>
<tr className="border-b border-slate-200 bg-slate-50/70 dark:border-slate-800 dark:bg-slate-800/40">
<th scope="col" className="w-12 px-4 py-3">
<label className="flex items-center justify-center">
<span className="sr-only">
{allSelected ? "Deselect all visible invoices" : "Select all visible invoices"}
</span>
<input
ref={headerCheckRef}
type="checkbox"
checked={allSelected}
onChange={toggleAll}
disabled={visibleIds.length === 0}
className={`h-4 w-4 rounded border-slate-300 accent-indigo-600 disabled:opacity-40 dark:border-slate-600 ${focusRing}`}
/>
</label>
</th>
{COLUMNS.map((col) => {
const active = sort.key === col.key;
const state: SortDir | "none" = active ? sort.dir : "none";
return (
<th
key={col.key}
scope="col"
aria-sort={active ? (sort.dir === "asc" ? "ascending" : "descending") : "none"}
className={`px-4 py-3 font-medium ${col.align === "right" ? "text-right" : "text-left"}`}
>
<button
type="button"
onClick={() => handleSort(col.key)}
className={`group inline-flex items-center gap-1.5 rounded text-xs font-semibold uppercase tracking-wide text-slate-500 transition-colors hover:text-slate-900 dark:text-slate-400 dark:hover:text-white ${col.align === "right" ? "flex-row-reverse" : ""} ${focusRing}`}
>
<span>{col.label}</span>
<SortGlyph state={state} />
</button>
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800">
{rows.length === 0 ? (
<tr>
<td colSpan={COLUMNS.length + 1} className="px-4 py-16 text-center">
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">No invoices found</p>
<p className="mt-1 text-sm text-slate-400 dark:text-slate-500">
Try a different search term or clear the status filter.
</p>
</td>
</tr>
) : (
rows.map((r) => {
const isSelected = selected.has(r.id);
return (
<tr
key={r.id}
className={`transition-colors ${
isSelected
? "bg-indigo-50/70 dark:bg-indigo-500/10"
: "hover:bg-slate-50 dark:hover:bg-slate-800/40"
}`}
>
<td className="px-4 py-3.5">
<label className="flex items-center justify-center">
<span className="sr-only">
{isSelected ? `Deselect ${r.invoice}` : `Select ${r.invoice}`}
</span>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleOne(r.id)}
className={`h-4 w-4 rounded border-slate-300 accent-indigo-600 dark:border-slate-600 ${focusRing}`}
/>
</label>
</td>
<th scope="row" className="whitespace-nowrap px-4 py-3.5 font-medium text-slate-900 dark:text-white">
{r.invoice}
</th>
<td className="px-4 py-3.5">
<div className="font-medium text-slate-800 dark:text-slate-100">{r.client}</div>
<div className="text-xs text-slate-400 dark:text-slate-500">{r.email}</div>
</td>
<td className="px-4 py-3.5">
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset ${statusBadge[r.status]}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${statusDot[r.status]} ${
r.status === "Overdue" ? "tbltbl-pulse-dot" : ""
}`}
/>
{r.status}
</span>
</td>
<td className="whitespace-nowrap px-4 py-3.5 text-right font-medium tabular-nums text-slate-900 dark:text-white">
{currencyFmt.format(r.amount)}
</td>
<td className="whitespace-nowrap px-4 py-3.5 text-slate-500 dark:text-slate-400">
{formatDate(r.date)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Footer */}
<div className="flex flex-col gap-3 border-t border-slate-200 p-4 text-sm sm:flex-row sm:items-center sm:justify-between sm:px-6 dark:border-slate-800">
<p className="text-slate-500 dark:text-slate-400" aria-live="polite">
Showing <span className="font-medium text-slate-700 dark:text-slate-200">{rows.length}</span> of{" "}
<span className="font-medium text-slate-700 dark:text-slate-200">{INVOICES.length}</span> invoices
</p>
{selected.size > 0 ? (
<div className="flex items-center gap-3">
<span className="text-slate-600 dark:text-slate-300">
<span className="font-semibold text-indigo-600 dark:text-indigo-400">{selected.size}</span> selected ·{" "}
<span className="font-semibold tabular-nums text-slate-900 dark:text-white">
{currencyFmt.format(selectedTotal)}
</span>
</span>
<button
type="button"
onClick={() => setSelected(new Set<string>())}
className={`rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 dark:hover:text-white ${focusRing}`}
>
Clear selection
</button>
</div>
) : (
<p className="text-slate-400 dark:text-slate-500">Select rows to see a running total.</p>
)}
</div>
</motion.div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Striped Table
Originalstriped-row table

Bordered Table
Originalbordered table

Hover Table
Originalrow-hover highlight table

Sortable Table
Originalsortable-column table

Selectable Table
Originaltable with row checkboxes and select-all

Sticky Header Table
Originalsticky header scroll table

Pagination Table
Originaltable with footer pagination

Search Table
Originaltable with a search filter

Expandable Table
Originaltable with expandable rows

Actions Table
Originaltable with row action menus

Status Badges Table
Originaltable with status badges

Compact Table
Originaldense compact table

