# Tables

> Storefront products card with a search field, product icon, name and SKU, category, inventory and price columns with sorting, and a row menu to copy SKU, duplicate, archive or delete.

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

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

## Install

```bash
npx shadcn@latest add https://shadcnuikit.com/r/tables4.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`, `empty`, `input-group`, `table`

## Files

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

```tsx
"use client";

import * as React from "react";
import {
  ColumnDef,
  SortingState,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table";
import {
  Archive,
  ArchiveRestore,
  ArrowDown,
  ArrowUp,
  ArrowUpDown,
  Check,
  Copy,
  CopyPlus,
  Headphones,
  Keyboard,
  Lamp,
  LucideIcon,
  Monitor,
  MoreHorizontal,
  PackageSearch,
  Search,
  Trash2,
  Webcam,
} from "lucide-react";

import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

type Product = {
  id: string;
  name: string;
  sku: string;
  category: string;
  price: number;
  stock: number;
  icon: LucideIcon;
  archived: boolean;
};

const initialProducts: Product[] = [
  {
    id: "p1",
    name: 'Studio Monitor 27"',
    sku: "DSK-2701",
    category: "Displays",
    price: 389,
    stock: 42,
    icon: Monitor,
    archived: false,
  },
  {
    id: "p2",
    name: "Low Profile Keyboard",
    sku: "INP-0412",
    category: "Input",
    price: 119.5,
    stock: 8,
    icon: Keyboard,
    archived: false,
  },
  {
    id: "p3",
    name: "Noise Cancelling Headset",
    sku: "AUD-1180",
    category: "Audio",
    price: 214,
    stock: 0,
    icon: Headphones,
    archived: false,
  },
  {
    id: "p4",
    name: "4K Streaming Webcam",
    sku: "CAM-0907",
    category: "Video",
    price: 159.99,
    stock: 23,
    icon: Webcam,
    archived: false,
  },
  {
    id: "p5",
    name: "Dimmable Desk Lamp",
    sku: "LGT-0330",
    category: "Lighting",
    price: 64.25,
    stock: 117,
    icon: Lamp,
    archived: true,
  },
];

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

function stockState(stock: number) {
  if (stock === 0)
    return { label: "Sold out", className: "text-rose-600 dark:text-rose-400" };
  if (stock < 10)
    return { label: `${stock} left`, className: "font-medium text-foreground" };
  return { label: `${stock} in stock`, className: "text-muted-foreground" };
}

function SortHeader({
  label,
  sorted,
  onClick,
  className,
}: {
  label: string;
  sorted: false | "asc" | "desc";
  onClick: () => void;
  className?: string;
}) {
  const Icon =
    sorted === "asc" ? ArrowUp : sorted === "desc" ? ArrowDown : ArrowUpDown;
  return (
    <Button
      variant="ghost"
      size="sm"
      onClick={onClick}
      aria-label={`Sort by ${label}`}
      className={cn("-ml-3 h-8 gap-1.5", className)}
    >
      {label}
      <Icon className={cn("size-3.5", !sorted && "text-muted-foreground")} />
    </Button>
  );
}

export default function ProductCatalogTable() {
  const [products, setProducts] = React.useState<Product[]>(initialProducts);
  const [sorting, setSorting] = React.useState<SortingState>([]);
  const [query, setQuery] = React.useState("");
  const [copiedId, setCopiedId] = React.useState<string | null>(null);

  const duplicate = React.useCallback((product: Product) => {
    setProducts((prev) => {
      const index = prev.findIndex((p) => p.id === product.id);
      const copyCount = prev.filter((p) =>
        p.sku.startsWith(product.sku),
      ).length;
      const clone: Product = {
        ...product,
        id: `${product.id}-copy-${copyCount}`,
        name: `${product.name} (copy)`,
        sku: `${product.sku}-C${copyCount}`,
        stock: 0,
      };
      const next = [...prev];
      next.splice(index + 1, 0, clone);
      return next;
    });
  }, []);

  const toggleArchive = React.useCallback((id: string) => {
    setProducts((prev) =>
      prev.map((p) => (p.id === id ? { ...p, archived: !p.archived } : p)),
    );
  }, []);

  const remove = React.useCallback((id: string) => {
    setProducts((prev) => prev.filter((p) => p.id !== id));
  }, []);

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

  const columns = React.useMemo<ColumnDef<Product>[]>(
    () => [
      {
        accessorKey: "name",
        header: ({ column }) => (
          <SortHeader
            label="Product"
            sorted={column.getIsSorted()}
            onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
          />
        ),
        cell: ({ row }) => {
          const product = row.original;
          const Icon = product.icon;
          return (
            <div className="flex items-center gap-3">
              <div className="bg-muted flex size-10 shrink-0 items-center justify-center rounded-xl border">
                <Icon className="text-foreground size-4.5" />
              </div>
              <div className="min-w-0">
                <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
                  <span className="font-medium">{product.name}</span>
                  {product.archived && (
                    <Badge variant="outline" className="text-muted-foreground">
                      Archived
                    </Badge>
                  )}
                </div>
                <div className="text-muted-foreground font-mono text-xs">
                  {product.sku}
                </div>
              </div>
            </div>
          );
        },
      },
      {
        accessorKey: "category",
        header: "Category",
        cell: ({ row }) => (
          <span className="text-muted-foreground">{row.original.category}</span>
        ),
      },
      {
        accessorKey: "stock",
        header: ({ column }) => (
          <SortHeader
            label="Inventory"
            sorted={column.getIsSorted()}
            onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
          />
        ),
        cell: ({ row }) => {
          const state = stockState(row.original.stock);
          return (
            <span className={cn("text-sm tabular-nums", state.className)}>
              {state.label}
            </span>
          );
        },
      },
      {
        accessorKey: "price",
        header: ({ column }) => (
          <div className="flex justify-end">
            <SortHeader
              label="Price"
              sorted={column.getIsSorted()}
              onClick={() =>
                column.toggleSorting(column.getIsSorted() === "asc")
              }
              className="-mr-3 ml-0"
            />
          </div>
        ),
        cell: ({ row }) => (
          <div className="text-right font-medium tabular-nums">
            {currency.format(row.original.price)}
          </div>
        ),
      },
      {
        id: "actions",
        enableHiding: false,
        header: () => <span className="sr-only">Actions</span>,
        cell: ({ row }) => {
          const product = row.original;
          return (
            <div className="flex justify-end">
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button
                    variant="ghost"
                    size="icon-sm"
                    aria-label={`Actions for ${product.name}`}
                  >
                    <MoreHorizontal />
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end" className="w-48">
                  <DropdownMenuItem onSelect={() => copySku(product)}>
                    {copiedId === product.id ? <Check /> : <Copy />}
                    {copiedId === product.id ? "SKU copied" : "Copy SKU"}
                  </DropdownMenuItem>
                  <DropdownMenuItem onSelect={() => duplicate(product)}>
                    <CopyPlus />
                    Duplicate
                  </DropdownMenuItem>
                  <DropdownMenuItem onSelect={() => toggleArchive(product.id)}>
                    {product.archived ? <ArchiveRestore /> : <Archive />}
                    {product.archived ? "Restore" : "Archive"}
                  </DropdownMenuItem>
                  <DropdownMenuSeparator />
                  <DropdownMenuItem
                    variant="destructive"
                    onSelect={() => remove(product.id)}
                  >
                    <Trash2 />
                    Delete
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </div>
          );
        },
      },
    ],
    [copiedId, copySku, duplicate, remove, toggleArchive],
  );

  const table = useReactTable({
    data: products,
    columns,
    state: { sorting, globalFilter: query },
    onSortingChange: setSorting,
    onGlobalFilterChange: setQuery,
    globalFilterFn: (row, _columnId, value: string) => {
      const needle = value.trim().toLowerCase();
      if (!needle) return true;
      const { name, sku, category } = row.original;
      return [name, sku, category].some((field) =>
        field.toLowerCase().includes(needle),
      );
    },
    getRowId: (row) => row.id,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
  });

  const activeProducts = products.filter((p) => !p.archived);
  const inventoryValue = activeProducts.reduce(
    (sum, p) => sum + p.price * p.stock,
    0,
  );
  const rows = table.getRowModel().rows;

  return (
    <Card className="mx-auto w-full max-w-4xl gap-0 py-0 shadow-none">
      <CardHeader className="flex flex-col gap-4 border-b py-5 sm:flex-row sm:items-center sm:justify-between">
        <div className="space-y-1">
          <CardTitle>Storefront products</CardTitle>
          <CardDescription>
            {activeProducts.length} live listings,{" "}
            {currency.format(inventoryValue)} on hand
          </CardDescription>
        </div>
        <CardAction className="w-full sm:w-64">
          <InputGroup>
            <InputGroupAddon>
              <Search />
            </InputGroupAddon>
            <InputGroupInput
              value={query}
              onChange={(event) => setQuery(event.target.value)}
              placeholder="Search name, SKU, category"
              aria-label="Search products"
            />
          </InputGroup>
        </CardAction>
      </CardHeader>

      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id} className="hover:bg-transparent">
              {headerGroup.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 first:pl-6 last:pr-6",
                    header.column.id === "category" && "hidden md:table-cell",
                    header.column.id === "stock" && "hidden sm:table-cell",
                    header.column.id === "price" && "text-right",
                  )}
                >
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.header,
                        header.getContext(),
                      )}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {rows.length ? (
            rows.map((row) => (
              <TableRow
                key={row.id}
                className={cn(
                  row.original.archived && "[&_td:not(:last-child)]:opacity-60",
                )}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell
                    key={cell.id}
                    className={cn(
                      "py-3.5 first:pl-6 last:pr-6",
                      cell.column.id === "name" && "whitespace-normal",
                      cell.column.id === "category" && "hidden md:table-cell",
                      cell.column.id === "stock" && "hidden sm:table-cell",
                    )}
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow className="hover:bg-transparent">
              <TableCell colSpan={columns.length} className="h-40 p-0">
                <Empty className="gap-3 p-4 whitespace-normal">
                  <EmptyHeader>
                    <EmptyMedia variant="icon">
                      <PackageSearch />
                    </EmptyMedia>
                    <EmptyTitle className="text-base">
                      No products found
                    </EmptyTitle>
                    <EmptyDescription>
                      {products.length
                        ? "Try a different search term."
                        : "The catalog is empty."}
                    </EmptyDescription>
                  </EmptyHeader>
                  {query && (
                    <EmptyContent>
                      <Button
                        variant="outline"
                        size="sm"
                        onClick={() => setQuery("")}
                      >
                        Clear search
                      </Button>
                    </EmptyContent>
                  )}
                </Empty>
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </Card>
  );
}
```
