Selectable Table
Original · freetable with row checkboxes and select-all
byWeb InnoventixReact + Tailwind
tableselectabletables
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-selectable.jsontable-selectable.tsx
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent, MouseEvent as ReactMouseEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type InvoiceStatus = "paid" | "pending" | "overdue" | "draft";
interface Invoice {
id: string;
number: string;
customer: string;
email: string;
amount: number;
status: InvoiceStatus;
issued: string;
}
const INITIAL_INVOICES: Invoice[] = [
{ id: "inv-4821", number: "INV-4821", customer: "Marisol Vega", email: "marisol@northloop.studio", amount: 2480, status: "paid", issued: "Jul 3, 2026" },
{ id: "inv-4822", number: "INV-4822", customer: "Devon Achebe", email: "devon@brightmesh.io", amount: 640, status: "pending", issued: "Jul 5, 2026" },
{ id: "inv-4823", number: "INV-4823", customer: "Hanna Lindqvist", email: "hanna@fjord-labs.se", amount: 12750, status: "overdue", issued: "Jun 18, 2026" },
{ id: "inv-4824", number: "INV-4824", customer: "Rashid Karim", email: "rashid@karim-partners.com", amount: 3900, status: "paid", issued: "Jul 8, 2026" },
{ id: "inv-4825", number: "INV-4825", customer: "Priya Nadella", email: "priya@cadence.dev", amount: 158, status: "draft", issued: "Jul 11, 2026" },
{ id: "inv-4826", number: "INV-4826", customer: "Tomás Iglesias", email: "tomas@monteverde.mx", amount: 5320, status: "pending", issued: "Jul 12, 2026" },
{ id: "inv-4827", number: "INV-4827", customer: "Aiko Watanabe", email: "aiko@sakura-ux.jp", amount: 890, status: "paid", issued: "Jul 14, 2026" },
{ id: "inv-4828", number: "INV-4828", customer: "Grace Okonkwo", email: "grace@lagos-forge.ng", amount: 7410, status: "overdue", issued: "Jun 29, 2026" },
];
const STATUS_STYLES: Record<InvoiceStatus, { label: string; className: string; dot: string }> = {
paid: {
label: "Paid",
className: "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
dot: "bg-emerald-500",
},
pending: {
label: "Pending",
className: "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
dot: "bg-amber-500",
},
overdue: {
label: "Overdue",
className: "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
dot: "bg-rose-500",
},
draft: {
label: "Draft",
className: "bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-500/10 dark:text-slate-300 dark:ring-slate-400/20",
dot: "bg-slate-400",
},
};
const AVATAR_GRADIENTS = [
"from-violet-500 to-indigo-500",
"from-sky-500 to-indigo-500",
"from-emerald-500 to-sky-500",
"from-rose-500 to-violet-500",
"from-amber-500 to-rose-500",
];
const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 0 });
function initialsOf(name: string): string {
return name
.split(" ")
.map((part) => part.charAt(0))
.slice(0, 2)
.join("")
.toUpperCase();
}
function gradientFor(id: string): string {
let sum = 0;
for (let i = 0; i < id.length; i++) sum += id.charCodeAt(i);
return AVATAR_GRADIENTS[sum % AVATAR_GRADIENTS.length];
}
function csvCell(value: string | number): string {
const s = String(value);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
interface RowCheckboxProps {
checked: boolean;
indeterminate?: boolean;
label: string;
animate: boolean;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
onClick?: (event: ReactMouseEvent<HTMLInputElement>) => void;
}
function RowCheckbox({ checked, indeterminate = false, label, animate, onChange, onClick }: RowCheckboxProps) {
const inputRef = useRef<HTMLInputElement>(null);
const active = checked || indeterminate;
useEffect(() => {
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<label className="relative inline-flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center">
<input
ref={inputRef}
type="checkbox"
checked={checked}
onChange={onChange}
onClick={onClick}
aria-label={label}
className="peer sr-only"
/>
<span
aria-hidden="true"
className={[
"flex h-5 w-5 items-center justify-center rounded-md border transition-colors duration-150",
"peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500/70 peer-focus-visible:ring-offset-2",
"peer-focus-visible:ring-offset-white dark:peer-focus-visible:ring-offset-slate-900",
active
? "border-violet-600 bg-violet-600 text-white dark:border-violet-500 dark:bg-violet-500"
: "border-slate-300 bg-white text-transparent hover:border-slate-400 dark:border-slate-600 dark:bg-slate-950 dark:hover:border-slate-500",
].join(" ")}
>
{indeterminate ? (
<svg viewBox="0 0 16 16" className={`h-3.5 w-3.5 ${animate ? "tblsel-pop" : ""}`} fill="none" aria-hidden="true">
<path d="M4 8h8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
) : checked ? (
<svg viewBox="0 0 16 16" className={`h-3.5 w-3.5 ${animate ? "tblsel-pop" : ""}`} fill="none" aria-hidden="true">
<path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : null}
</span>
</label>
);
}
type SortKey = "amount" | "issued";
interface SortState {
key: SortKey;
dir: "asc" | "desc";
}
function ariaSortValue(state: SortState, key: SortKey): "ascending" | "descending" | "none" {
if (state.key !== key) return "none";
return state.dir === "asc" ? "ascending" : "descending";
}
export default function SelectableInvoiceTable() {
const reduce = useReducedMotion();
const animate = !reduce;
const [rows, setRows] = useState<Invoice[]>(INITIAL_INVOICES);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set<string>());
const [sort, setSort] = useState<SortState>({ key: "issued", dir: "desc" });
const shiftHeldRef = useRef(false);
const lastIndexRef = useRef<number | null>(null);
const sortedRows = useMemo(() => {
const copy = [...rows];
copy.sort((a, b) => {
if (sort.key === "amount") {
return sort.dir === "asc" ? a.amount - b.amount : b.amount - a.amount;
}
const at = new Date(a.issued).getTime();
const bt = new Date(b.issued).getTime();
return sort.dir === "asc" ? at - bt : bt - at;
});
return copy;
}, [rows, sort]);
const selectedCount = selectedIds.size;
const allSelected = sortedRows.length > 0 && sortedRows.every((r) => selectedIds.has(r.id));
const someSelected = selectedCount > 0 && !allSelected;
function toggleAll() {
setSelectedIds(() => {
if (sortedRows.length > 0 && sortedRows.every((r) => selectedIds.has(r.id))) {
return new Set<string>();
}
return new Set(sortedRows.map((r) => r.id));
});
lastIndexRef.current = null;
}
function handleRowToggle(index: number, id: string) {
const shift = shiftHeldRef.current;
setSelectedIds((prev) => {
const next = new Set(prev);
const willSelect = !next.has(id);
if (shift && lastIndexRef.current !== null) {
const start = Math.min(lastIndexRef.current, index);
const end = Math.max(lastIndexRef.current, index);
for (let i = start; i <= end; i++) {
const rowId = sortedRows[i].id;
if (willSelect) next.add(rowId);
else next.delete(rowId);
}
} else if (willSelect) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
lastIndexRef.current = index;
shiftHeldRef.current = false;
}
function toggleSort(key: SortKey) {
setSort((prev) => (prev.key === key ? { key, dir: prev.dir === "asc" ? "desc" : "asc" } : { key, dir: "asc" }));
}
function clearSelection() {
setSelectedIds(new Set<string>());
lastIndexRef.current = null;
}
function markSelectedPaid() {
setRows((prev) => prev.map((r) => (selectedIds.has(r.id) ? { ...r, status: "paid" } : r)));
}
function deleteSelected() {
setRows((prev) => prev.filter((r) => !selectedIds.has(r.id)));
clearSelection();
}
function restoreData() {
setRows(INITIAL_INVOICES);
clearSelection();
}
function exportSelected() {
const chosen = rows.filter((r) => selectedIds.has(r.id));
if (chosen.length === 0) return;
const header = ["Invoice", "Customer", "Email", "Amount", "Status", "Issued"];
const lines = [
header.join(","),
...chosen.map((r) => [r.number, r.customer, r.email, r.amount, STATUS_STYLES[r.status].label, r.issued].map(csvCell).join(",")),
];
const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "selected-invoices.csv";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
const totalSelectedAmount = rows.filter((r) => selectedIds.has(r.id)).reduce((sum, r) => sum + r.amount, 0);
const sortableHeaderBase =
"group inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-xs font-semibold uppercase tracking-wide text-slate-500 transition-colors hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/70 dark:text-slate-400 dark:hover:text-slate-100";
const bulkButtonBase =
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 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 sm:px-6 lg:px-8 dark:bg-slate-950">
<style>{`
@keyframes tblsel-pop {
0% { transform: scale(0.4); opacity: 0; }
55% { transform: scale(1.18); }
100% { transform: scale(1); opacity: 1; }
}
.tblsel-pop { animation: tblsel-pop 180ms cubic-bezier(0.34, 1.56, 0.64, 1); transform-origin: center; }
@media (prefers-reduced-motion: reduce) {
.tblsel-pop { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-5xl">
<header className="mb-6 flex flex-col gap-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-violet-600 dark:text-violet-400">Billing</p>
<h2 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">Invoices</h2>
<p className="max-w-prose text-sm text-slate-500 dark:text-slate-400">
Select rows to run bulk actions. Hold <kbd className="rounded border border-slate-300 bg-white px-1 font-sans text-[0.7rem] text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300">Shift</kbd> and click to select a range.
</p>
</header>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
{/* Toolbar */}
<div className="flex min-h-[3.75rem] items-center justify-between gap-3 border-b border-slate-200 bg-gradient-to-r from-white to-slate-50 px-4 py-3 sm:px-5 dark:border-slate-800 dark:from-slate-900 dark:to-slate-900/40">
<div className="min-w-0">
<p className="text-sm font-semibold text-slate-900 dark:text-white" aria-live="polite">
{selectedCount > 0 ? `${selectedCount} selected` : `${rows.length} invoice${rows.length === 1 ? "" : "s"}`}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
{selectedCount > 0 ? `${currency.format(totalSelectedAmount)} total` : "Manage and reconcile client billing"}
</p>
</div>
<AnimatePresence mode="wait" initial={false}>
{selectedCount > 0 ? (
<motion.div
key="bulk"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}
transition={{ duration: reduce ? 0 : 0.18, ease: "easeOut" }}
className="flex flex-wrap items-center justify-end gap-2"
>
<button
type="button"
onClick={markSelectedPaid}
className={`${bulkButtonBase} bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:ring-emerald-500`}
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="7.25" stroke="currentColor" strokeWidth="1.5" />
<path d="M7 10l2 2 4-4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Mark paid
</button>
<button
type="button"
onClick={exportSelected}
className={`${bulkButtonBase} bg-slate-100 text-slate-700 hover:bg-slate-200 focus-visible:ring-slate-400 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700`}
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" aria-hidden="true">
<path d="M10 3v9m0 0l-3-3m3 3l3-3M4 15.5h12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Export
</button>
<button
type="button"
onClick={deleteSelected}
className={`${bulkButtonBase} bg-rose-50 text-rose-700 hover:bg-rose-100 focus-visible:ring-rose-500 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20`}
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" aria-hidden="true">
<path d="M4.5 6h11M8 6V4.5h4V6m-6 0l.6 9h6.8l.6-9M9 9v4m2-4v4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Delete
</button>
<button
type="button"
onClick={clearSelection}
aria-label="Clear selection"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" aria-hidden="true">
<path d="M6 6l8 8M14 6l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</motion.div>
) : (
<motion.span
key="hint"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}
transition={{ duration: reduce ? 0 : 0.18, ease: "easeOut" }}
className="hidden shrink-0 items-center gap-1.5 rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500 sm:inline-flex dark:bg-slate-800 dark:text-slate-400"
>
<span className="h-1.5 w-1.5 rounded-full bg-violet-500" aria-hidden="true" />
No rows selected
</motion.span>
)}
</AnimatePresence>
</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 row selection, bulk actions, and sortable amount and issue date columns.
</caption>
<thead>
<tr className="border-b border-slate-200 bg-slate-50/60 dark:border-slate-800 dark:bg-slate-900/60">
<th scope="col" className="w-12 px-4 py-3">
<RowCheckbox
checked={allSelected}
indeterminate={someSelected}
animate={animate}
label={allSelected ? "Deselect all invoices" : "Select all invoices"}
onChange={toggleAll}
/>
</th>
<th scope="col" className="px-4 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Customer
</th>
<th scope="col" className="hidden px-4 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500 sm:table-cell dark:text-slate-400">
Invoice
</th>
<th scope="col" aria-sort={ariaSortValue(sort, "amount")} className="px-4 py-3">
<button type="button" onClick={() => toggleSort("amount")} className={sortableHeaderBase}>
Amount
<SortGlyph active={sort.key === "amount"} dir={sort.dir} />
</button>
</th>
<th scope="col" className="px-4 py-3 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Status
</th>
<th scope="col" aria-sort={ariaSortValue(sort, "issued")} className="hidden px-4 py-3 md:table-cell">
<button type="button" onClick={() => toggleSort("issued")} className={sortableHeaderBase}>
Issued
<SortGlyph active={sort.key === "issued"} dir={sort.dir} />
</button>
</th>
</tr>
</thead>
<tbody>
{sortedRows.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-16 text-center">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">No invoices left</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">Every row was deleted in this session.</p>
<button
type="button"
onClick={restoreData}
className="mt-4 inline-flex items-center gap-1.5 rounded-lg bg-violet-600 px-3 py-2 text-xs font-semibold text-white transition-colors hover:bg-violet-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" aria-hidden="true">
<path d="M4 10a6 6 0 1 1 1.8 4.3M4 10V6m0 4h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Restore sample data
</button>
</td>
</tr>
) : (
sortedRows.map((row, index) => {
const isSelected = selectedIds.has(row.id);
const status = STATUS_STYLES[row.status];
return (
<tr
key={row.id}
data-selected={isSelected}
className={[
"border-b border-slate-100 transition-colors last:border-0 dark:border-slate-800/70",
isSelected
? "bg-violet-50/70 dark:bg-violet-500/10"
: "hover:bg-slate-50 dark:hover:bg-slate-800/40",
].join(" ")}
>
<td className="px-4 py-3 align-middle">
<RowCheckbox
checked={isSelected}
animate={animate}
label={`Select invoice ${row.number} for ${row.customer}`}
onClick={(event) => {
shiftHeldRef.current = event.shiftKey;
}}
onChange={() => handleRowToggle(index, row.id)}
/>
</td>
<td className="px-4 py-3 align-middle">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-gradient-to-br ${gradientFor(row.id)} text-xs font-bold text-white shadow-sm`}
>
{initialsOf(row.customer)}
</span>
<div className="min-w-0">
<p className="truncate font-medium text-slate-900 dark:text-white">{row.customer}</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">{row.email}</p>
</div>
</div>
</td>
<td className="hidden px-4 py-3 align-middle font-mono text-xs text-slate-500 sm:table-cell dark:text-slate-400">
{row.number}
</td>
<td className="px-4 py-3 align-middle font-semibold tabular-nums text-slate-900 dark:text-white">
{currency.format(row.amount)}
</td>
<td className="px-4 py-3 align-middle">
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset ${status.className}`}>
<span className={`h-1.5 w-1.5 rounded-full ${status.dot}`} aria-hidden="true" />
{status.label}
</span>
</td>
<td className="hidden px-4 py-3 align-middle text-slate-500 md:table-cell dark:text-slate-400">
{row.issued}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-3 border-t border-slate-200 px-4 py-3 text-xs text-slate-500 sm:px-5 dark:border-slate-800 dark:text-slate-400">
<span>
{selectedCount} of {rows.length} row{rows.length === 1 ? "" : "s"} selected
</span>
{selectedCount > 0 && (
<button
type="button"
onClick={clearSelection}
className="font-semibold text-violet-600 transition-colors hover:text-violet-500 focus-visible:outline-none focus-visible:underline dark:text-violet-400 dark:hover:text-violet-300"
>
Clear
</button>
)}
</div>
</div>
</div>
</section>
);
}
function SortGlyph({ active, dir }: { active: boolean; dir: "asc" | "desc" }) {
return (
<svg viewBox="0 0 12 12" className={`h-3 w-3 transition-opacity ${active ? "opacity-100" : "opacity-30 group-hover:opacity-60"}`} fill="none" aria-hidden="true">
<path
d="M6 2.5l3 3M6 2.5l-3 3M6 2.5v7"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
className={active && dir === "desc" ? "origin-center [transform:rotate(180deg)]" : ""}
/>
</svg>
);
}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

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

