# Tables

> Platform tickets card with status tabs and counts, sortable key and priority, owner avatars, label badges, a status pill dropdown per row, a columns visibility menu and five row pagination.

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

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

## Install

```bash
npx shadcn@latest add https://shadcnuikit.com/r/tables6.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`, `badge`, `button`, `card`, `dropdown-menu`, `empty`, `table`, `tabs`

## Files

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

```tsx
"use client";

import * as React from "react";
import {
  ColumnDef,
  ColumnFiltersState,
  PaginationState,
  SortingState,
  VisibilityState,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table";
import {
  ArrowDown,
  ArrowUp,
  ArrowUpDown,
  ChevronLeft,
  ChevronRight,
  Circle,
  CircleCheck,
  CircleDashed,
  CircleDot,
  Columns3,
  Inbox,
  LucideIcon,
  SignalHigh,
  SignalLow,
  SignalMedium,
  TriangleAlert,
} from "lucide-react";

import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";

const STATUSES = ["Backlog", "Active", "Review", "Shipped"] as const;
type Status = (typeof STATUSES)[number];
type Priority = 1 | 2 | 3 | 4;

type Ticket = {
  id: number;
  title: string;
  label: string;
  owner: { name: string; avatar: string };
  priority: Priority;
  status: Status;
};

const people = {
  noor: { name: "Noor Haddad", avatar: "https://i.pravatar.cc/100?img=45" },
  jonas: { name: "Jonas Weber", avatar: "https://i.pravatar.cc/100?img=15" },
  mei: { name: "Mei Tanaka", avatar: "https://i.pravatar.cc/100?img=44" },
  owen: { name: "Owen Gallagher", avatar: "https://i.pravatar.cc/100?img=59" },
};

const initialTickets: Ticket[] = [
  {
    id: 1287,
    title: "Checkout total ignores regional tax",
    label: "Defect",
    owner: people.noor,
    priority: 4,
    status: "Active",
  },
  {
    id: 1286,
    title: "Export audit log as CSV",
    label: "Request",
    owner: people.jonas,
    priority: 2,
    status: "Backlog",
  },
  {
    id: 1285,
    title: "Session expires during file upload",
    label: "Defect",
    owner: people.mei,
    priority: 3,
    status: "Review",
  },
  {
    id: 1284,
    title: "Move image resizing to a worker queue",
    label: "Chore",
    owner: people.owen,
    priority: 2,
    status: "Active",
  },
  {
    id: 1283,
    title: "Keyboard shortcuts for the editor",
    label: "Request",
    owner: people.jonas,
    priority: 1,
    status: "Shipped",
  },
  {
    id: 1282,
    title: "Webhook retries fire twice",
    label: "Defect",
    owner: people.noor,
    priority: 4,
    status: "Review",
  },
  {
    id: 1281,
    title: "Drop legacy v1 search endpoint",
    label: "Chore",
    owner: people.owen,
    priority: 1,
    status: "Backlog",
  },
  {
    id: 1280,
    title: "Saved filters on the reports page",
    label: "Request",
    owner: people.mei,
    priority: 2,
    status: "Shipped",
  },
  {
    id: 1279,
    title: "Avatar crop shifts on Safari",
    label: "Defect",
    owner: people.jonas,
    priority: 3,
    status: "Active",
  },
  {
    id: 1278,
    title: "Upgrade the PDF rendering library",
    label: "Chore",
    owner: people.noor,
    priority: 1,
    status: "Shipped",
  },
  {
    id: 1277,
    title: "Team mentions in comment threads",
    label: "Request",
    owner: people.owen,
    priority: 3,
    status: "Backlog",
  },
];

const statusMeta: Record<Status, { icon: LucideIcon; className: string }> = {
  Backlog: { icon: CircleDashed, className: "text-muted-foreground" },
  Active: { icon: CircleDot, className: "text-foreground" },
  Review: { icon: Circle, className: "text-foreground" },
  Shipped: {
    icon: CircleCheck,
    className: "text-green-600 dark:text-green-400",
  },
};

const priorityMeta: Record<
  Priority,
  { label: string; icon: LucideIcon; className: string }
> = {
  4: {
    label: "Urgent",
    icon: TriangleAlert,
    className: "text-rose-600 dark:text-rose-400",
  },
  3: { label: "High", icon: SignalHigh, className: "text-foreground" },
  2: { label: "Medium", icon: SignalMedium, className: "text-foreground" },
  1: { label: "Low", icon: SignalLow, className: "text-muted-foreground" },
};

const PAGE_SIZE = 5;
const responsiveColumn: Record<string, string> = {
  owner: "hidden md:table-cell",
  priority: "hidden sm:table-cell",
};
const optionalColumns: Record<string, string> = {
  owner: "Owner",
  priority: "Priority",
};

function initials(name: string) {
  return name
    .split(" ")
    .map((part) => part[0])
    .join("");
}

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

export default function IssueTrackerTable() {
  const [tickets, setTickets] = React.useState<Ticket[]>(initialTickets);
  const [sorting, setSorting] = React.useState<SortingState>([
    { id: "priority", desc: true },
  ]);
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    [],
  );
  const [columnVisibility, setColumnVisibility] =
    React.useState<VisibilityState>({});
  const [pagination, setPagination] = React.useState<PaginationState>({
    pageIndex: 0,
    pageSize: PAGE_SIZE,
  });

  const setStatus = React.useCallback((id: number, status: Status) => {
    setTickets((prev) => prev.map((t) => (t.id === id ? { ...t, status } : t)));
  }, []);

  const columns = React.useMemo<ColumnDef<Ticket>[]>(
    () => [
      {
        accessorKey: "id",
        header: ({ column }) => (
          <SortButton
            label="Key"
            sorted={column.getIsSorted()}
            onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
          />
        ),
        cell: ({ row }) => (
          <span className="text-muted-foreground font-mono text-xs">
            OPS-{row.original.id}
          </span>
        ),
      },
      {
        accessorKey: "title",
        header: "Ticket",
        cell: ({ row }) => (
          <div className="flex items-center gap-2">
            <span className="font-medium">{row.original.title}</span>
            <Badge
              variant="outline"
              className="text-muted-foreground hidden lg:inline-flex"
            >
              {row.original.label}
            </Badge>
          </div>
        ),
      },
      {
        id: "owner",
        accessorFn: (row) => row.owner.name,
        header: "Owner",
        cell: ({ row }) => (
          <div className="flex items-center gap-2">
            <Avatar size="sm">
              <AvatarImage src={row.original.owner.avatar} alt="" />
              <AvatarFallback>
                {initials(row.original.owner.name)}
              </AvatarFallback>
            </Avatar>
            <span className="text-sm">{row.original.owner.name}</span>
          </div>
        ),
      },
      {
        accessorKey: "priority",
        sortingFn: (a, b) =>
          a.original.priority - b.original.priority ||
          a.original.id - b.original.id,
        header: ({ column }) => (
          <SortButton
            label="Priority"
            sorted={column.getIsSorted()}
            onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
          />
        ),
        cell: ({ row }) => {
          const meta = priorityMeta[row.original.priority];
          const Icon = meta.icon;
          return (
            <span
              className={cn(
                "inline-flex items-center gap-1.5 text-sm",
                meta.className,
              )}
            >
              <Icon className="size-4" />
              {meta.label}
            </span>
          );
        },
      },
      {
        accessorKey: "status",
        header: "Status",
        filterFn: (row, columnId, value: string) =>
          row.getValue(columnId) === value,
        cell: ({ row }) => {
          const ticket = row.original;
          const meta = statusMeta[ticket.status];
          const Icon = meta.icon;
          return (
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <Button
                  variant="outline"
                  size="sm"
                  className="h-7 gap-1.5 pr-2.5 pl-2 text-xs"
                  aria-label={`Change status of OPS-${ticket.id}, currently ${ticket.status}`}
                >
                  <Icon className={cn("size-3.5", meta.className)} />
                  {ticket.status}
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end" className="w-44">
                <DropdownMenuLabel className="text-muted-foreground text-xs">
                  Move to
                </DropdownMenuLabel>
                <DropdownMenuRadioGroup
                  value={ticket.status}
                  onValueChange={(value) =>
                    setStatus(ticket.id, value as Status)
                  }
                >
                  {STATUSES.map((status) => {
                    const ItemIcon = statusMeta[status].icon;
                    return (
                      <DropdownMenuRadioItem key={status} value={status}>
                        <ItemIcon className={statusMeta[status].className} />
                        {status}
                      </DropdownMenuRadioItem>
                    );
                  })}
                </DropdownMenuRadioGroup>
              </DropdownMenuContent>
            </DropdownMenu>
          );
        },
      },
    ],
    [setStatus],
  );

  const table = useReactTable({
    data: tickets,
    columns,
    state: { sorting, columnFilters, columnVisibility, pagination },
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onColumnVisibilityChange: setColumnVisibility,
    onPaginationChange: setPagination,
    autoResetPageIndex: false,
    getRowId: (row) => String(row.id),
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });

  const statusFilter =
    (table.getColumn("status")?.getFilterValue() as string) ?? "all";
  const counts = STATUSES.reduce(
    (acc, status) => ({
      ...acc,
      [status]: tickets.filter((t) => t.status === status).length,
    }),
    {} as Record<Status, number>,
  );
  const openCount = tickets.length - counts.Shipped;
  const urgentOpen = tickets.filter(
    (t) => t.priority === 4 && t.status !== "Shipped",
  ).length;

  const filteredCount = table.getFilteredRowModel().rows.length;
  const pageCount = Math.max(table.getPageCount(), 1);
  const { pageIndex } = table.getState().pagination;

  React.useEffect(() => {
    if (pageIndex > pageCount - 1) table.setPageIndex(pageCount - 1);
  }, [pageIndex, pageCount, table]);

  const firstRow = filteredCount === 0 ? 0 : pageIndex * PAGE_SIZE + 1;
  const lastRow = Math.min((pageIndex + 1) * PAGE_SIZE, filteredCount);
  const rows = table.getRowModel().rows;

  return (
    <Card className="mx-auto w-full max-w-5xl gap-0 py-0 shadow-none">
      <CardHeader className="gap-1 border-b py-5">
        <CardTitle>Platform tickets</CardTitle>
        <CardDescription>
          {openCount} open, {urgentOpen} urgent. Click a status pill to move a
          ticket.
        </CardDescription>
      </CardHeader>

      <div className="flex flex-col gap-3 border-b px-6 py-3 sm:flex-row sm:items-center sm:justify-between">
        <Tabs
          value={statusFilter}
          onValueChange={(value) => {
            table
              .getColumn("status")
              ?.setFilterValue(value === "all" ? undefined : value);
            table.setPageIndex(0);
          }}
          className="max-w-full [scrollbar-width:none] overflow-x-auto"
        >
          <TabsList>
            <TabsTrigger value="all" className="px-2 sm:px-3">
              All{" "}
              <span className="text-muted-foreground hidden tabular-nums sm:inline">
                {tickets.length}
              </span>
            </TabsTrigger>
            {STATUSES.map((status) => (
              <TabsTrigger key={status} value={status} className="px-2 sm:px-3">
                {status}
                <span className="text-muted-foreground hidden tabular-nums sm:inline">
                  {counts[status]}
                </span>
              </TabsTrigger>
            ))}
          </TabsList>
        </Tabs>

        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button
              variant="outline"
              size="sm"
              className="hidden md:inline-flex"
            >
              <Columns3 />
              Columns
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end" className="w-44">
            <DropdownMenuLabel className="text-muted-foreground text-xs">
              Show columns
            </DropdownMenuLabel>
            {Object.entries(optionalColumns).map(([id, label]) => (
              <DropdownMenuCheckboxItem
                key={id}
                checked={table.getColumn(id)?.getIsVisible()}
                onCheckedChange={(value) =>
                  table.getColumn(id)?.toggleVisibility(!!value)
                }
                onSelect={(event) => event.preventDefault()}
              >
                {label}
              </DropdownMenuCheckboxItem>
            ))}
          </DropdownMenuContent>
        </DropdownMenu>
      </div>

      <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 === "id" && "w-28",
                    responsiveColumn[header.column.id],
                    header.column.id === "status" && "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}>
                {row.getVisibleCells().map((cell) => (
                  <TableCell
                    key={cell.id}
                    className={cn(
                      "py-3 first:pl-6 last:pr-6",
                      cell.column.id === "status" && "text-right",
                      cell.column.id === "title" && "whitespace-normal",
                      responsiveColumn[cell.column.id],
                    )}
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow className="hover:bg-transparent">
              <TableCell
                colSpan={table.getVisibleLeafColumns().length}
                className="h-[17.5rem] p-0"
              >
                <Empty className="p-4 whitespace-normal">
                  <EmptyHeader>
                    <EmptyMedia variant="icon">
                      <Inbox />
                    </EmptyMedia>
                    <EmptyTitle className="text-base">
                      Nothing in this lane
                    </EmptyTitle>
                    <EmptyDescription>
                      Move a ticket here from its status pill.
                    </EmptyDescription>
                  </EmptyHeader>
                </Empty>
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>

      <div className="flex items-center justify-between gap-3 border-t px-6 py-3">
        <p className="text-muted-foreground text-sm tabular-nums">
          {firstRow}-{lastRow} of {filteredCount}
        </p>
        <div className="flex items-center gap-2">
          <span className="text-muted-foreground text-sm tabular-nums">
            Page {pageIndex + 1} of {pageCount}
          </span>
          <Button
            variant="outline"
            size="icon-sm"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
            aria-label="Previous page"
          >
            <ChevronLeft />
          </Button>
          <Button
            variant="outline"
            size="icon-sm"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
            aria-label="Next page"
          >
            <ChevronRight />
          </Button>
        </div>
      </div>
    </Card>
  );
}
```
