Responsive Stack Table
Original · freetable that stacks to cards on mobile
byWeb InnoventixReact + Tailwind
tableresponsivestacktables
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-responsive-stack.jsontable-responsive-stack.tsx
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Status = "paid" | "pending" | "overdue" | "draft";
type StatusFilter = Status | "all";
type SortKey = "client" | "amount" | "status" | "due";
type SortDir = "asc" | "desc";
interface Invoice {
id: string;
invoice: string;
client: string;
email: string;
amount: number;
status: Status;
due: string;
}
const INVOICES: Invoice[] = [
{ id: "i1", invoice: "INV-2087", client: "Northwind Analytics", email: "billing@northwind.io", amount: 4800, status: "paid", due: "2026-06-28" },
{ id: "i2", invoice: "INV-2091", client: "Cedar & Pine Studio", email: "ap@cedarpine.co", amount: 1250, status: "pending", due: "2026-07-22" },
{ id: "i3", invoice: "INV-2094", client: "Halcyon Robotics", email: "finance@halcyon.tech", amount: 12600, status: "overdue", due: "2026-07-02" },
{ id: "i4", invoice: "INV-2098", client: "Meridian Freight Co", email: "accounts@meridianfreight.com", amount: 3320, status: "paid", due: "2026-06-15" },
{ id: "i5", invoice: "INV-2103", client: "Blue Harbor Ventures", email: "invoices@blueharbor.vc", amount: 8750, status: "pending", due: "2026-07-30" },
{ id: "i6", invoice: "INV-2107", client: "Fernwood Interiors", email: "hello@fernwood.design", amount: 640, status: "draft", due: "2026-08-05" },
{ id: "i7", invoice: "INV-2110", client: "Atlas Grid Energy", email: "ap@atlasgrid.energy", amount: 21500, status: "overdue", due: "2026-06-25" },
{ id: "i8", invoice: "INV-2114", client: "Quill & Co Legal", email: "billing@quillco.law", amount: 5400, status: "paid", due: "2026-07-08" },
{ id: "i9", invoice: "INV-2119", client: "Sunset Bakery Group", email: "finance@sunsetbakery.com", amount: 980, status: "draft", due: "2026-08-12" },
{ id: "i10", invoice: "INV-2122", client: "Vertex Health Labs", email: "ap@vertexhealth.io", amount: 7200, status: "pending", due: "2026-07-19" },
];
const STATUS_OPTIONS: { key: StatusFilter; label: string }[] = [
{ key: "all", label: "All" },
{ key: "paid", label: "Paid" },
{ key: "pending", label: "Pending" },
{ key: "overdue", label: "Overdue" },
{ key: "draft", label: "Draft" },
];
const STATUS_RANK: Record<Status, number> = { overdue: 0, pending: 1, draft: 2, paid: 3 };
const usd = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
function fmtDate(iso: string): string {
const d = new Date(`${iso}T00:00:00`);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
function compare(a: Invoice, b: Invoice, key: SortKey): number {
switch (key) {
case "client":
return a.client.localeCompare(b.client);
case "amount":
return a.amount - b.amount;
case "due":
return a.due.localeCompare(b.due);
case "status":
return STATUS_RANK[a.status] - STATUS_RANK[b.status];
}
}
const checkboxClass =
"h-4 w-4 shrink-0 cursor-pointer rounded border-slate-300 accent-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-600 dark:focus-visible:ring-offset-slate-900";
const labelClass =
"text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400 md:hidden";
function SortIcon({ dir }: { dir: SortDir | null }) {
return (
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4.5 6.5 8 3l3.5 3.5" className={dir === "asc" ? "opacity-100" : "opacity-30"} />
<path d="M4.5 9.5 8 13l3.5-3.5" className={dir === "desc" ? "opacity-100" : "opacity-30"} />
</svg>
);
}
interface SortButtonProps {
label: string;
active: boolean;
dir: SortDir | null;
align?: "left" | "right";
onClick: () => void;
}
function SortButton({ label, active, dir, align = "left", onClick }: SortButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center gap-1.5 rounded text-xs font-semibold uppercase tracking-wide transition-colors 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 ${
align === "right" ? "flex-row-reverse" : ""
} ${
active
? "text-slate-900 dark:text-slate-100"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
<span>{label}</span>
<SortIcon dir={dir} />
</button>
);
}
function StatusBadge({ status }: { status: Status }) {
const styles: Record<Status, { label: string; dot: string; chip: string }> = {
paid: {
label: "Paid",
dot: "bg-emerald-500",
chip: "bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
},
pending: {
label: "Pending",
dot: "bg-amber-500",
chip: "bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
},
overdue: {
label: "Overdue",
dot: "bg-rose-500",
chip: "bg-rose-50 text-rose-700 ring-1 ring-inset ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
},
draft: {
label: "Draft",
dot: "bg-slate-400",
chip: "bg-slate-100 text-slate-600 ring-1 ring-inset ring-slate-500/20 dark:bg-slate-500/10 dark:text-slate-300 dark:ring-slate-400/25",
},
};
const s = styles[status];
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${s.chip}`}>
<span className={`h-1.5 w-1.5 rounded-full ${s.dot} ${status === "overdue" ? "trs-dot" : ""}`} />
{s.label}
</span>
);
}
function SearchIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round">
<circle cx="9" cy="9" r="6" />
<path d="m17 17-3.2-3.2" />
</svg>
);
}
function DownloadIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round">
<path d="M10 3v9m0 0 3.5-3.5M10 12 6.5 8.5" />
<path d="M4 15.5h12" />
</svg>
);
}
function BellIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round">
<path d="M10 3a4.5 4.5 0 0 0-4.5 4.5c0 3.2-1 4.5-1.5 5h12c-.5-.5-1.5-1.8-1.5-5A4.5 4.5 0 0 0 10 3Z" />
<path d="M8.5 15.5a1.5 1.5 0 0 0 3 0" />
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="m4.5 10.5 3.5 3.5 7.5-8" />
</svg>
);
}
function XIcon() {
return (
<svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round">
<path d="m5 5 10 10M15 5 5 15" />
</svg>
);
}
export default function TableResponsiveStack() {
const prefersReduced = useReducedMotion();
const reduced = !!prefersReduced;
const [invoices, setInvoices] = useState<Invoice[]>(INVOICES);
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: "due", dir: "asc" });
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [toast, setToast] = useState<string | null>(null);
const selectAllRef = useRef<HTMLInputElement>(null);
const toastTimer = useRef<number | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const rows = invoices.filter((r) => {
const matchesQuery = !q || r.client.toLowerCase().includes(q) || r.invoice.toLowerCase().includes(q);
const matchesStatus = statusFilter === "all" || r.status === statusFilter;
return matchesQuery && matchesStatus;
});
return [...rows].sort((a, b) => {
const c = compare(a, b, sort.key);
return sort.dir === "asc" ? c : -c;
});
}, [invoices, query, statusFilter, sort]);
const counts = useMemo(() => {
const base: Record<StatusFilter, number> = { all: invoices.length, paid: 0, pending: 0, overdue: 0, draft: 0 };
for (const r of invoices) base[r.status] += 1;
return base;
}, [invoices]);
const filteredTotal = useMemo(() => filtered.reduce((sum, r) => sum + r.amount, 0), [filtered]);
const selectedCount = selectedIds.size;
const selectedTotal = useMemo(
() => invoices.reduce((sum, r) => (selectedIds.has(r.id) ? sum + r.amount : sum), 0),
[invoices, selectedIds],
);
const allChecked = filtered.length > 0 && filtered.every((r) => selectedIds.has(r.id));
useEffect(() => {
const el = selectAllRef.current;
if (!el) return;
const visible = filtered.map((r) => r.id);
const selectedVisible = visible.filter((id) => selectedIds.has(id)).length;
el.indeterminate = selectedVisible > 0 && selectedVisible < visible.length;
}, [filtered, selectedIds]);
useEffect(() => () => {
if (toastTimer.current) window.clearTimeout(toastTimer.current);
}, []);
function showToast(msg: string) {
setToast(msg);
if (toastTimer.current) window.clearTimeout(toastTimer.current);
toastTimer.current = window.setTimeout(() => setToast(null), 3200);
}
function ariaSort(key: SortKey): "none" | "ascending" | "descending" {
if (sort.key !== key) return "none";
return sort.dir === "asc" ? "ascending" : "descending";
}
function handleSort(key: SortKey) {
setSort((prev) =>
prev.key === key
? { key, dir: prev.dir === "asc" ? "desc" : "asc" }
: { key, dir: key === "amount" ? "desc" : "asc" },
);
}
function toggleOne(id: string) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function toggleAll() {
setSelectedIds((prev) => {
const next = new Set(prev);
const visible = filtered.map((r) => r.id);
const allSel = visible.length > 0 && visible.every((id) => next.has(id));
if (allSel) visible.forEach((id) => next.delete(id));
else visible.forEach((id) => next.add(id));
return next;
});
}
function clearSelection() {
setSelectedIds(new Set());
}
function markPaid() {
const ids = new Set(selectedIds);
setInvoices((prev) => prev.map((r) => (ids.has(r.id) ? { ...r, status: "paid" } : r)));
setSelectedIds(new Set());
showToast(`Marked ${ids.size} invoice${ids.size === 1 ? "" : "s"} as paid`);
}
function sendReminder() {
showToast(`Reminder sent to ${selectedCount} client${selectedCount === 1 ? "" : "s"}`);
}
function resetFilters() {
setQuery("");
setStatusFilter("all");
}
function exportCsv() {
const header = ["Invoice", "Client", "Email", "Amount", "Status", "Due"].join(",");
const lines = filtered.map((r) => [r.invoice, r.client, r.email, r.amount, r.status, r.due].join(","));
const csv = [header, ...lines].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "invoices.csv";
a.click();
URL.revokeObjectURL(url);
showToast(`Exported ${filtered.length} invoice${filtered.length === 1 ? "" : "s"}`);
}
const cellBase = "md:table-cell md:px-3 md:py-3.5 md:align-middle";
return (
<section className="relative w-full bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:px-6 lg:py-24">
<style>{`
@keyframes trs-fade-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes trs-dot-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(244, 63, 94, 0.55); }
50% { box-shadow: 0 0 0 4px rgba(244, 63, 94, 0); }
}
.trs-fade-up { animation: trs-fade-up 0.5s ease-out both; }
.trs-dot { animation: trs-dot-pulse 2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.trs-fade-up, .trs-dot { animation: none !important; }
}
`}</style>
<div className="trs-fade-up mx-auto max-w-4xl">
<div 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-4 dark:border-slate-800 sm:flex-row sm:items-start sm:justify-between sm:p-6">
<div>
<h2 className="text-lg font-semibold tracking-tight text-slate-900 dark:text-white">
Outstanding invoices
</h2>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Sort, filter, and act on billing across your active clients.
</p>
</div>
<button
type="button"
onClick={exportCsv}
className="inline-flex items-center gap-2 self-start rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<DownloadIcon />
Export
</button>
</div>
{/* Toolbar */}
<div className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between sm:px-6 sm:py-4">
<div className="relative w-full sm:max-w-xs">
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-slate-400 dark:text-slate-500">
<SearchIcon />
</span>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search client or invoice…"
aria-label="Search invoices"
className="w-full rounded-lg border border-slate-300 bg-white py-2 pl-9 pr-3 text-sm text-slate-900 placeholder-slate-400 shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder-slate-500"
/>
</div>
<div role="group" aria-label="Filter by status" className="flex flex-wrap gap-1.5">
{STATUS_OPTIONS.map((opt) => {
const active = statusFilter === opt.key;
return (
<button
key={opt.key}
type="button"
aria-pressed={active}
onClick={() => setStatusFilter(opt.key)}
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors 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 ${
active
? "bg-slate-900 text-white dark:bg-white dark:text-slate-900"
: "bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
}`}
>
{opt.label}
<span
className={`rounded-full px-1.5 text-[10px] font-semibold tabular-nums ${
active
? "bg-white/20 text-white dark:bg-slate-900/10 dark:text-slate-900"
: "bg-white text-slate-500 dark:bg-slate-900 dark:text-slate-400"
}`}
>
{counts[opt.key]}
</span>
</button>
);
})}
</div>
</div>
{/* Bulk action bar */}
<AnimatePresence initial={false}>
{selectedCount > 0 && (
<motion.div
key="bulk"
initial={reduced ? false : { opacity: 0, height: 0 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, height: "auto" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="flex flex-wrap items-center gap-3 border-y border-slate-200 bg-indigo-50/70 px-4 py-3 dark:border-slate-800 dark:bg-indigo-500/10 sm:px-6">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">
<span className="tabular-nums">{selectedCount}</span> selected
<span className="text-slate-400 dark:text-slate-500"> · </span>
<span className="tabular-nums">{usd.format(selectedTotal)}</span>
</p>
<div className="ml-auto flex flex-wrap items-center gap-2">
<button
type="button"
onClick={sendReminder}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-indigo-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
<BellIcon />
Send reminder
</button>
<button
type="button"
onClick={markPaid}
className="inline-flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-indigo-50 dark:focus-visible:ring-offset-slate-900"
>
<CheckIcon />
Mark as paid
</button>
<button
type="button"
onClick={clearSelection}
aria-label="Clear selection"
className="inline-flex items-center justify-center rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-white hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-indigo-50 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
>
<XIcon />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Table / cards */}
<div className="overflow-x-auto px-4 pb-2 md:px-2 md:pb-0">
<table className="block w-full border-collapse text-sm md:table">
<caption className="sr-only">
Outstanding invoices, sortable by client, amount, status, and due date.
</caption>
<thead className="hidden md:table-header-group">
<tr className="border-b border-slate-200 text-left dark:border-slate-800">
<th scope="col" aria-sort={ariaSort("client")} className="px-3 py-3 md:pl-4">
<div className="flex items-center gap-3">
<input
ref={selectAllRef}
type="checkbox"
checked={allChecked}
onChange={toggleAll}
aria-label="Select all invoices"
className={checkboxClass}
/>
<SortButton
label="Client"
active={sort.key === "client"}
dir={sort.key === "client" ? sort.dir : null}
onClick={() => handleSort("client")}
/>
</div>
</th>
<th scope="col" aria-sort={ariaSort("amount")} className="px-3 py-3 text-right">
<SortButton
label="Amount"
align="right"
active={sort.key === "amount"}
dir={sort.key === "amount" ? sort.dir : null}
onClick={() => handleSort("amount")}
/>
</th>
<th scope="col" aria-sort={ariaSort("status")} className="px-3 py-3">
<SortButton
label="Status"
active={sort.key === "status"}
dir={sort.key === "status" ? sort.dir : null}
onClick={() => handleSort("status")}
/>
</th>
<th scope="col" aria-sort={ariaSort("due")} className="px-3 py-3 md:pr-4">
<SortButton
label="Due date"
active={sort.key === "due"}
dir={sort.key === "due" ? sort.dir : null}
onClick={() => handleSort("due")}
/>
</th>
</tr>
</thead>
<tbody className="block md:table-row-group">
{filtered.length === 0 ? (
<tr className="block md:table-row">
<td colSpan={4} className="block px-4 py-12 text-center md:table-cell">
<p className="text-sm font-medium text-slate-600 dark:text-slate-300">
No invoices match your filters
</p>
<button
type="button"
onClick={resetFilters}
className="mt-2 rounded text-sm font-medium text-indigo-600 transition-colors hover:text-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-indigo-400 dark:focus-visible:ring-offset-slate-900"
>
Clear filters
</button>
</td>
</tr>
) : (
filtered.map((row) => {
const checked = selectedIds.has(row.id);
return (
<tr
key={row.id}
className={`mb-3 block rounded-xl border p-4 shadow-sm transition-colors last:mb-0 md:mb-0 md:table-row md:rounded-none md:border-x-0 md:border-t-0 md:border-b md:p-0 md:shadow-none md:border-slate-100 dark:md:border-slate-800 ${
checked
? "border-indigo-300 bg-indigo-50 dark:border-indigo-500/40 dark:bg-indigo-500/10 md:bg-indigo-50 dark:md:bg-indigo-500/10"
: "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900 md:bg-transparent md:hover:bg-slate-50 dark:md:bg-transparent dark:md:hover:bg-slate-800/40"
}`}
>
{/* Client + checkbox */}
<td className={`flex items-start justify-between gap-3 ${cellBase} md:pl-4`}>
<div className="flex items-start gap-3">
<input
type="checkbox"
checked={checked}
onChange={() => toggleOne(row.id)}
aria-label={`Select invoice ${row.invoice} for ${row.client}`}
className={`${checkboxClass} mt-0.5`}
/>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
{row.client}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-slate-500 dark:text-slate-400">
<span className="font-mono">{row.invoice}</span>
<span aria-hidden="true" className="text-slate-300 dark:text-slate-600">
•
</span>
<span className="truncate">{row.email}</span>
</div>
</div>
</div>
</td>
{/* Amount */}
<td className={`mt-3 flex items-center justify-between gap-4 border-t border-slate-100 pt-3 dark:border-slate-800 md:mt-0 md:border-t-0 md:pt-0 md:text-right ${cellBase}`}>
<span className={labelClass}>Amount</span>
<span className="font-semibold tabular-nums text-slate-900 dark:text-slate-100">
{usd.format(row.amount)}
</span>
</td>
{/* Status */}
<td className={`mt-2 flex items-center justify-between gap-4 md:mt-0 ${cellBase}`}>
<span className={labelClass}>Status</span>
<StatusBadge status={row.status} />
</td>
{/* Due date */}
<td className={`mt-2 flex items-center justify-between gap-4 md:mt-0 md:pr-4 ${cellBase}`}>
<span className={labelClass}>Due date</span>
<span
className={`text-sm tabular-nums ${
row.status === "overdue"
? "font-medium text-rose-600 dark:text-rose-400"
: "text-slate-600 dark:text-slate-300"
}`}
>
{fmtDate(row.due)}
</span>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Footer */}
<div className="flex flex-col gap-2 border-t border-slate-200 p-4 text-sm text-slate-500 dark:border-slate-800 dark:text-slate-400 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<span>
Showing{" "}
<span className="font-medium tabular-nums text-slate-700 dark:text-slate-200">{filtered.length}</span> of{" "}
<span className="tabular-nums">{invoices.length}</span> invoices
</span>
<span>
Filtered total{" "}
<span className="font-semibold tabular-nums text-slate-800 dark:text-slate-100">
{usd.format(filteredTotal)}
</span>
</span>
</div>
</div>
</div>
{/* Toast */}
<div aria-live="polite" className="pointer-events-none fixed inset-x-0 bottom-6 z-50 flex justify-center px-4">
<AnimatePresence>
{toast && (
<motion.div
key={toast}
initial={reduced ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: 12 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="pointer-events-auto flex items-center gap-2 rounded-full bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-lg dark:bg-white dark:text-slate-900"
>
<CheckIcon />
{toast}
</motion.div>
)}
</AnimatePresence>
</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 →
Basic Table
Originalclean basic data table

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

