Web InnoventixFreeCode

Responsive Stack Table

Original · free

table that stacks to cards on mobile

byWeb InnoventixReact + Tailwind
tableresponsivestacktables
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-responsive-stack.json
table-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 quote

Similar components

Browse all →