Web InnoventixFreeCode

Selectable Table

Original · free

table with row checkboxes and select-all

byWeb InnoventixReact + Tailwind
tableselectabletables
Open

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.json
table-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 quote

Similar components

Browse all →