# Tables

> Recent invoices card with a status toggle group showing counts, invoice, status, method, issued and sortable amount columns, a row menu to copy the ID or mark paid, and a visible total line.

A shadcn/ui block from the [@shadcnuikit](https://shadcnuikit.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcnuikit/tables1).

- Page, with a live preview: https://shadcn.exchange/r/shadcnuikit/tables1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcnuikit/tables1-light-b8a2ce9ae1778556.png
- Registry item: https://shadcnuikit.com/r/tables1.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcnuikit.com/r/tables1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@tanstack/react-table`, `lucide-react`
- Registry items: `badge`, `button`, `card`, `dropdown-menu`, `table`, `toggle-group`

## Files

### `contents/blocks/dashboard-ui/tables/1/index.tsx`

```tsx
"use client";

import * as React from "react";
import {
  type ColumnDef,
  type SortingState,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table";
import {
  ArrowDown,
  ArrowUp,
  ArrowUpDown,
  Building2,
  Check,
  Copy,
  CreditCard,
  MoreHorizontal,
  Wallet,
} from "lucide-react";

import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

type Status = "paid" | "due" | "overdue";
type Method = "card" | "wire" | "wallet";

type Invoice = {
  id: string;
  client: string;
  issued: string;
  method: Method;
  status: Status;
  amount: number;
};

const initialInvoices: Invoice[] = [
  {
    id: "BL-1042",
    client: "Harbor & Pine",
    issued: "2026-09-28",
    method: "card",
    status: "paid",
    amount: 1840,
  },
  {
    id: "BL-1041",
    client: "Quillstone Labs",
    issued: "2026-09-24",
    method: "wire",
    status: "due",
    amount: 4625.5,
  },
  {
    id: "BL-1040",
    client: "Mosaic Health",
    issued: "2026-09-19",
    method: "wallet",
    status: "paid",
    amount: 612.75,
  },
  {
    id: "BL-1039",
    client: "Northbeam Co.",
    issued: "2026-09-12",
    method: "wire",
    status: "overdue",
    amount: 3290,
  },
  {
    id: "BL-1038",
    client: "Fernway Studio",
    issued: "2026-09-08",
    method: "card",
    status: "due",
    amount: 975,
  },
  {
    id: "BL-1037",
    client: "Atlas Freight",
    issued: "2026-09-02",
    method: "wire",
    status: "overdue",
    amount: 2150.4,
  },
];

const statusMeta: Record<
  Status,
  { label: string; className: string; dot: string }
> = {
  paid: {
    label: "Paid",
    className: "text-green-600 dark:text-green-400",
    dot: "bg-green-600 dark:bg-green-400",
  },
  due: {
    label: "Due",
    className: "text-muted-foreground",
    dot: "bg-muted-foreground",
  },
  overdue: {
    label: "Overdue",
    className: "text-rose-600 dark:text-rose-400",
    dot: "bg-rose-600 dark:bg-rose-400",
  },
};

const methodMeta: Record<
  Method,
  { label: string; icon: React.ComponentType<{ className?: string }> }
> = {
  card: { label: "Card", icon: CreditCard },
  wire: { label: "Wire transfer", icon: Building2 },
  wallet: { label: "Digital wallet", icon: Wallet },
};

const currency = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
});

const formatDate = (value: string) =>
  new Date(`${value}T00:00:00Z`).toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
    timeZone: "UTC",
  });

type Filter = "all" | Status;

const filters: { value: Filter; label: string }[] = [
  { value: "all", label: "All" },
  { value: "paid", label: "Paid" },
  { value: "due", label: "Due" },
  { value: "overdue", label: "Overdue" },
];

export default function RecentInvoicesTable() {
  const [invoices, setInvoices] = React.useState(initialInvoices);
  const [filter, setFilter] = React.useState<Filter>("all");
  const [sorting, setSorting] = React.useState<SortingState>([]);
  const [copiedId, setCopiedId] = React.useState<string | null>(null);

  const markPaid = React.useCallback((id: string) => {
    setInvoices((prev) =>
      prev.map((inv) => (inv.id === id ? { ...inv, status: "paid" } : inv)),
    );
  }, []);

  const copyId = React.useCallback((id: string) => {
    navigator.clipboard?.writeText(id).catch(() => undefined);
    setCopiedId(id);
    window.setTimeout(
      () => setCopiedId((current) => (current === id ? null : current)),
      1500,
    );
  }, []);

  const columns = React.useMemo<ColumnDef<Invoice>[]>(
    () => [
      {
        accessorKey: "id",
        header: "Invoice",
        cell: ({ row }) => (
          <div className="flex flex-col">
            <span className="font-medium">{row.original.id}</span>
            <span className="text-muted-foreground text-xs">
              {row.original.client}
              <span
                className={cn(
                  "sm:hidden",
                  statusMeta[row.original.status].className,
                )}
              >
                {" "}
                · {statusMeta[row.original.status].label}
              </span>
            </span>
          </div>
        ),
      },
      {
        accessorKey: "status",
        header: "Status",
        filterFn: "equals",
        meta: { className: "hidden sm:table-cell" },
        cell: ({ row }) => {
          const meta = statusMeta[row.original.status];
          return (
            <Badge variant="outline" className={cn("gap-1.5", meta.className)}>
              <span
                className={cn("size-1.5 rounded-full", meta.dot)}
                aria-hidden
              />
              {meta.label}
            </Badge>
          );
        },
      },
      {
        accessorKey: "method",
        header: "Method",
        meta: { className: "hidden md:table-cell" },
        cell: ({ row }) => {
          const meta = methodMeta[row.original.method];
          const Icon = meta.icon;
          return (
            <span className="text-muted-foreground flex items-center gap-2">
              <Icon className="size-4" />
              {meta.label}
            </span>
          );
        },
      },
      {
        accessorKey: "issued",
        header: "Issued",
        meta: { className: "hidden sm:table-cell" },
        cell: ({ row }) => (
          <span className="text-muted-foreground">
            {formatDate(row.original.issued)}
          </span>
        ),
      },
      {
        accessorKey: "amount",
        meta: { className: "text-right" },
        header: ({ column }) => {
          const sorted = column.getIsSorted();
          const Icon =
            sorted === "asc"
              ? ArrowUp
              : sorted === "desc"
                ? ArrowDown
                : ArrowUpDown;
          return (
            <Button
              variant="ghost"
              size="sm"
              className="-mr-3 ml-auto flex"
              onClick={() => column.toggleSorting(sorted === "asc")}
              aria-label="Sort by amount"
            >
              Amount
              <Icon className={cn(!sorted && "text-muted-foreground")} />
            </Button>
          );
        },
        cell: ({ row }) => (
          <span className="font-medium tabular-nums">
            {currency.format(row.original.amount)}
          </span>
        ),
      },
      {
        id: "actions",
        enableSorting: false,
        meta: { className: "w-10 pr-0 pl-1 text-right" },
        cell: ({ row }) => {
          const invoice = row.original;
          return (
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <Button
                  variant="ghost"
                  size="icon-sm"
                  aria-label={`Actions for ${invoice.id}`}
                >
                  <MoreHorizontal />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end" className="w-44">
                <DropdownMenuLabel>{invoice.id}</DropdownMenuLabel>
                <DropdownMenuItem onSelect={() => copyId(invoice.id)}>
                  <Copy />
                  Copy invoice ID
                </DropdownMenuItem>
                <DropdownMenuSeparator />
                <DropdownMenuItem
                  disabled={invoice.status === "paid"}
                  onSelect={() => markPaid(invoice.id)}
                >
                  <Check />
                  Mark as paid
                </DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          );
        },
      },
    ],
    [copyId, markPaid],
  );

  const columnFilters = React.useMemo(
    () => (filter === "all" ? [] : [{ id: "status", value: filter }]),
    [filter],
  );

  const table = useReactTable({
    data: invoices,
    columns,
    state: { sorting, columnFilters },
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

  const rows = table.getRowModel().rows;
  const visibleTotal = rows.reduce((sum, row) => sum + row.original.amount, 0);
  const outstanding = invoices
    .filter((inv) => inv.status !== "paid")
    .reduce((sum, inv) => sum + inv.amount, 0);

  const countFor = (value: Filter) =>
    value === "all"
      ? invoices.length
      : invoices.filter((inv) => inv.status === value).length;

  return (
    <Card className="mx-auto w-full max-w-4xl shadow-none">
      <CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
        <div className="flex flex-col gap-1.5">
          <CardTitle className="text-lg">Recent invoices</CardTitle>
          <CardDescription>
            {currency.format(outstanding)} still outstanding across{" "}
            {countFor("due") + countFor("overdue")} invoices
          </CardDescription>
        </div>
        <div>
          <ToggleGroup
            type="single"
            variant="outline"
            size="sm"
            spacing={0}
            value={filter}
            onValueChange={(value) => value && setFilter(value as Filter)}
            aria-label="Filter by status"
          >
            {filters.map((item) => (
              <ToggleGroupItem
                key={item.value}
                value={item.value}
                className="gap-1.5 px-2.5"
              >
                {item.label}
                <span className="text-muted-foreground text-xs tabular-nums">
                  {countFor(item.value)}
                </span>
              </ToggleGroupItem>
            ))}
          </ToggleGroup>
        </div>
      </CardHeader>
      <CardContent>
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((group) => (
              <TableRow key={group.id} className="hover:bg-transparent">
                {group.headers.map((header) => (
                  <TableHead
                    key={header.id}
                    aria-sort={
                      header.column.getIsSorted() === "asc"
                        ? "ascending"
                        : header.column.getIsSorted() === "desc"
                          ? "descending"
                          : undefined
                    }
                    className={cn(
                      "text-muted-foreground",
                      (
                        header.column.columnDef.meta as
                          { className?: string } | undefined
                      )?.className,
                    )}
                  >
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext(),
                        )}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {rows.length ? (
              rows.map((row) => (
                <TableRow key={row.id}>
                  {row.getVisibleCells().map((cell) => (
                    <TableCell
                      key={cell.id}
                      className={
                        (
                          cell.column.columnDef.meta as
                            { className?: string } | undefined
                        )?.className
                      }
                    >
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext(),
                      )}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow className="hover:bg-transparent">
                <TableCell
                  colSpan={columns.length}
                  className="text-muted-foreground h-24 text-center"
                >
                  No invoices match this filter.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
        <p className="text-muted-foreground mt-4 text-sm">
          {copiedId ? (
            <span className="text-foreground inline-flex items-center gap-1.5">
              <Check className="size-4" />
              Copied {copiedId} to clipboard
            </span>
          ) : (
            <>
              Showing {rows.length} of {invoices.length} invoices, totaling{" "}
              <span className="text-foreground font-medium tabular-nums">
                {currency.format(visibleTotal)}
              </span>
            </>
          )}
        </p>
      </CardContent>
    </Card>
  );
}
```
