# Tables

> Launch checklist with All, Open and Done tabs, a progress bar, done checkboxes that strike through tasks, owner avatars, due dates and a status dropdown with four states per row.

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

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

## Install

```bash
npx shadcn@latest add https://shadcnuikit.com/r/tables3.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: `avatar`, `button`, `checkbox`, `dropdown-menu`, `progress`, `table`, `tabs`

## Files

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

```tsx
"use client";

import * as React from "react";
import {
  type ColumnDef,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  useReactTable,
} from "@tanstack/react-table";
import {
  ChevronDown,
  Circle,
  CircleCheck,
  CircleDashed,
  CircleX,
} from "lucide-react";

import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Progress } from "@/components/ui/progress";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";

type Status = "todo" | "active" | "blocked" | "done";

type Task = {
  id: string;
  title: string;
  owner: { name: string; initials: string };
  due: string;
  status: Status;
};

const initialTasks: Task[] = [
  {
    id: "t1",
    title: "Finalize pricing page copy",
    owner: { name: "Maya Ortiz", initials: "MO" },
    due: "2026-10-06",
    status: "done",
  },
  {
    id: "t2",
    title: "Record product walkthrough video",
    owner: { name: "Jonah Reyes", initials: "JR" },
    due: "2026-10-08",
    status: "active",
  },
  {
    id: "t3",
    title: "Set up status page alerts",
    owner: { name: "Priya Shah", initials: "PS" },
    due: "2026-10-09",
    status: "blocked",
  },
  {
    id: "t4",
    title: "Migrate billing webhooks",
    owner: { name: "Elena Novak", initials: "EN" },
    due: "2026-10-10",
    status: "active",
  },
  {
    id: "t5",
    title: "Draft launch email sequence",
    owner: { name: "Maya Ortiz", initials: "MO" },
    due: "2026-10-12",
    status: "todo",
  },
  {
    id: "t6",
    title: "Run load test on public API",
    owner: { name: "Theo Park", initials: "TP" },
    due: "2026-10-13",
    status: "todo",
  },
];

const statusMeta: Record<
  Status,
  {
    label: string;
    icon: React.ComponentType<{ className?: string }>;
    className: string;
  }
> = {
  todo: { label: "To do", icon: Circle, className: "text-muted-foreground" },
  active: {
    label: "In progress",
    icon: CircleDashed,
    className: "text-foreground",
  },
  blocked: {
    label: "Blocked",
    icon: CircleX,
    className: "text-rose-600 dark:text-rose-400",
  },
  done: {
    label: "Done",
    icon: CircleCheck,
    className: "text-green-600 dark:text-green-400",
  },
};

const statusOrder: Status[] = ["todo", "active", "blocked", "done"];

type Filter = "all" | "open" | "done";

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

export default function LaunchChecklistTable() {
  const [tasks, setTasks] = React.useState(initialTasks);
  const [filter, setFilter] = React.useState<Filter>("all");

  const setStatus = React.useCallback((id: string, status: Status) => {
    setTasks((prev) =>
      prev.map((task) => (task.id === id ? { ...task, status } : task)),
    );
  }, []);

  const columns = React.useMemo<ColumnDef<Task>[]>(
    () => [
      {
        id: "complete",
        meta: { className: "w-10" },
        cell: ({ row }) => (
          <Checkbox
            checked={row.original.status === "done"}
            onCheckedChange={(checked) =>
              setStatus(row.original.id, checked ? "done" : "todo")
            }
            aria-label={`Mark "${row.original.title}" as done`}
          />
        ),
      },
      {
        accessorKey: "title",
        header: "Task",
        meta: { className: "w-full min-w-40 whitespace-normal" },
        cell: ({ row }) => {
          const done = row.original.status === "done";
          return (
            <div className="flex min-w-0 flex-col">
              <span
                className={cn(
                  "font-medium transition-colors",
                  done && "text-muted-foreground line-through",
                )}
              >
                {row.original.title}
              </span>
              <span className="text-muted-foreground text-xs sm:hidden">
                {row.original.owner.name} · Due {formatDue(row.original.due)}
              </span>
            </div>
          );
        },
      },
      {
        id: "owner",
        header: "Owner",
        meta: { className: "hidden sm:table-cell" },
        cell: ({ row }) => (
          <div className="flex items-center gap-2">
            <Avatar className="size-6">
              <AvatarFallback className="text-[10px]">
                {row.original.owner.initials}
              </AvatarFallback>
            </Avatar>
            <span className="text-muted-foreground">
              {row.original.owner.name}
            </span>
          </div>
        ),
      },
      {
        accessorKey: "due",
        header: "Due",
        meta: { className: "hidden md:table-cell" },
        cell: ({ row }) => (
          <span className="text-muted-foreground tabular-nums">
            {formatDue(row.original.due)}
          </span>
        ),
      },
      {
        accessorKey: "status",
        header: () => <span className="block text-right">Status</span>,
        meta: { className: "text-right" },
        filterFn: (row, _columnId, value: Filter) => {
          if (value === "done") return row.original.status === "done";
          if (value === "open") return row.original.status !== "done";
          return true;
        },
        cell: ({ row }) => {
          const meta = statusMeta[row.original.status];
          const Icon = meta.icon;
          return (
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <Button
                  variant="outline"
                  size="xs"
                  aria-label={`Change status, currently ${meta.label}`}
                  className={cn("h-7 gap-1.5 px-2.5", meta.className)}
                >
                  <Icon />
                  {meta.label}
                  <ChevronDown className="text-muted-foreground" />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end" className="w-40">
                <DropdownMenuLabel>Set status</DropdownMenuLabel>
                <DropdownMenuSeparator />
                <DropdownMenuRadioGroup
                  value={row.original.status}
                  onValueChange={(value) =>
                    setStatus(row.original.id, value as Status)
                  }
                >
                  {statusOrder.map((status) => {
                    const option = statusMeta[status];
                    const OptionIcon = option.icon;
                    return (
                      <DropdownMenuRadioItem key={status} value={status}>
                        <OptionIcon className={option.className} />
                        {option.label}
                      </DropdownMenuRadioItem>
                    );
                  })}
                </DropdownMenuRadioGroup>
              </DropdownMenuContent>
            </DropdownMenu>
          );
        },
      },
    ],
    [setStatus],
  );

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

  const table = useReactTable({
    data: tasks,
    columns,
    state: { columnFilters },
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
  });

  const rows = table.getRowModel().rows;
  const doneCount = tasks.filter((task) => task.status === "done").length;
  const blockedCount = tasks.filter((task) => task.status === "blocked").length;
  const percent = Math.round((doneCount / tasks.length) * 100);

  const counts: Record<Filter, number> = {
    all: tasks.length,
    open: tasks.length - doneCount,
    done: doneCount,
  };

  return (
    <div className="mx-auto flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border">
      <div className="flex flex-col gap-4 p-4 sm:p-5">
        <div className="flex flex-wrap items-start justify-between gap-3">
          <div className="flex flex-col gap-1">
            <h3 className="font-heading text-lg font-medium">
              Launch checklist
            </h3>
            <p className="text-muted-foreground text-sm">
              {doneCount} of {tasks.length} tasks complete
              {blockedCount > 0 && (
                <span className="text-rose-600 dark:text-rose-400">
                  , {blockedCount} blocked
                </span>
              )}
            </p>
          </div>
          <Tabs
            value={filter}
            onValueChange={(value) => setFilter(value as Filter)}
          >
            <TabsList>
              {(["all", "open", "done"] as Filter[]).map((value) => (
                <TabsTrigger
                  key={value}
                  value={value}
                  className="gap-1.5 capitalize"
                >
                  {value}
                  <span className="text-muted-foreground text-xs tabular-nums">
                    {counts[value]}
                  </span>
                </TabsTrigger>
              ))}
            </TabsList>
          </Tabs>
        </div>
        <div className="flex items-center gap-3">
          <Progress
            value={percent}
            className="flex-1"
            aria-label="Checklist progress"
          />
          <span className="text-muted-foreground w-10 text-right text-xs font-medium tabular-nums">
            {percent}%
          </span>
        </div>
      </div>
      <Table>
        <TableHeader className="bg-muted/50 border-t">
          {table.getHeaderGroups().map((group) => (
            <TableRow key={group.id} className="hover:bg-transparent">
              {group.headers.map((header, index) => (
                <TableHead
                  key={header.id}
                  className={cn(
                    "text-muted-foreground h-10",
                    index === 0 && "pl-4 sm:pl-5",
                    index === group.headers.length - 1 && "pr-4 sm:pr-5",
                    (
                      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, index, cells) => (
                  <TableCell
                    key={cell.id}
                    className={cn(
                      index === 0 && "pl-4 sm:pl-5",
                      index === cells.length - 1 && "pr-4 sm:pr-5",
                      (
                        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"
              >
                {filter === "done"
                  ? "Nothing finished yet."
                  : "Everything is done. Time to ship."}
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  );
}
```
