Web InnoventixFreeCode

Basic Table

Original · free

clean basic data table

byWeb InnoventixReact + Tailwind
tablebasictables
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-basic.json
table-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 quote

Similar components

Browse all →