Cosskit

Data Table with Filters

Sortable customer table with search, status and plan filters, a totals row, pagination, and per-row action menus.

pnpm dlx shadcn@latest add @cosskit/table-filters

Requires the @cosskit namespace in your components.json.

Open full page

Code

"use client";

import { useMemo, useState } from "react";
import {
  ArrowDownIcon,
  ArrowUpIcon,
  ChevronLeftIcon,
  ChevronRightIcon,
  ChevronsUpDownIcon,
  DownloadIcon,
  MailIcon,
  MoreHorizontalIcon,
  PlusIcon,
  SearchIcon,
  SearchXIcon,
  SquarePenIcon,
  Trash2Icon,
  UserRoundIcon,
} from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import {
  Menu,
  MenuItem,
  MenuPopup,
  MenuSeparator,
  MenuTrigger,
} from "@/components/ui/menu";
import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
} from "@/components/ui/pagination";
import {
  Select,
  SelectItem,
  SelectPopup,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Table,
  TableBody,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

// ---------------------------------------------------------------------------
// Data
//
// Swap `customers` for your own rows — every filter, sort, and page control
// below reads from this array and nothing else. `createdAt` is kept as a
// sortable ISO string alongside a pre-formatted `created` label so the table
// never has to format a date at render time (formatting a Date on the server
// and again on the client is a classic hydration mismatch).
// ---------------------------------------------------------------------------

type CustomerStatus = "active" | "trialing" | "past_due" | "canceled";

interface Customer {
  id: string;
  name: string;
  initials: string;
  email: string;
  company: string;
  status: CustomerStatus;
  plan: string;
  mrr: number;
  /** ISO date — sorts lexicographically, so no Date parsing needed. */
  createdAt: string;
  /** Display label for `createdAt`. */
  created: string;
}

const customers: Customer[] = [
  { id: "1", name: "Sarah Park", initials: "SP", email: "sarah@northwind.io", company: "Northwind", status: "active", plan: "Scale", mrr: 2400, createdAt: "2025-03-14", created: "Mar 14, 2025" },
  { id: "2", name: "James Mercer", initials: "JM", email: "j.mercer@lumen.dev", company: "Lumen Labs", status: "trialing", plan: "Growth", mrr: 0, createdAt: "2026-07-28", created: "Jul 28, 2026" },
  { id: "3", name: "Lena Costa", initials: "LC", email: "lena@fieldnotes.app", company: "Fieldnotes", status: "active", plan: "Growth", mrr: 890, createdAt: "2025-11-02", created: "Nov 2, 2025" },
  { id: "4", name: "Raj Anand", initials: "RA", email: "raj@corvus.co", company: "Corvus", status: "past_due", plan: "Starter", mrr: 149, createdAt: "2026-01-19", created: "Jan 19, 2026" },
  { id: "5", name: "Tanya Wolfe", initials: "TW", email: "tanya@hollowpoint.io", company: "Hollowpoint", status: "canceled", plan: "Starter", mrr: 0, createdAt: "2025-06-30", created: "Jun 30, 2025" },
  { id: "6", name: "Marcus Bell", initials: "MB", email: "marcus@arboreal.com", company: "Arboreal", status: "active", plan: "Enterprise", mrr: 7800, createdAt: "2024-09-11", created: "Sep 11, 2024" },
  { id: "7", name: "Priya Raman", initials: "PR", email: "priya@stellarbase.io", company: "Stellarbase", status: "active", plan: "Scale", mrr: 2400, createdAt: "2025-08-05", created: "Aug 5, 2025" },
  { id: "8", name: "Dmitri Volkov", initials: "DV", email: "d.volkov@quanta.sh", company: "Quanta", status: "trialing", plan: "Scale", mrr: 0, createdAt: "2026-08-01", created: "Aug 1, 2026" },
  { id: "9", name: "Amara Osei", initials: "AO", email: "amara@bellwether.co", company: "Bellwether", status: "active", plan: "Growth", mrr: 890, createdAt: "2025-12-16", created: "Dec 16, 2025" },
  { id: "10", name: "Elliot Shaw", initials: "ES", email: "elliot@paperkite.io", company: "Paperkite", status: "past_due", plan: "Growth", mrr: 890, createdAt: "2025-04-22", created: "Apr 22, 2025" },
  { id: "11", name: "Nora Lindqvist", initials: "NL", email: "nora@saltmarsh.dev", company: "Saltmarsh", status: "active", plan: "Starter", mrr: 149, createdAt: "2026-02-08", created: "Feb 8, 2026" },
  { id: "12", name: "Hugo Ferreira", initials: "HF", email: "hugo@tessellate.app", company: "Tessellate", status: "active", plan: "Enterprise", mrr: 6200, createdAt: "2024-11-30", created: "Nov 30, 2024" },
  { id: "13", name: "Keiko Tanaka", initials: "KT", email: "keiko@driftwood.io", company: "Driftwood", status: "canceled", plan: "Growth", mrr: 0, createdAt: "2025-01-07", created: "Jan 7, 2025" },
  { id: "14", name: "Owen Brady", initials: "OB", email: "owen@ironvale.com", company: "Ironvale", status: "active", plan: "Scale", mrr: 2400, createdAt: "2025-09-23", created: "Sep 23, 2025" },
  { id: "15", name: "Ines Duarte", initials: "ID", email: "ines@marginalia.co", company: "Marginalia", status: "trialing", plan: "Starter", mrr: 0, createdAt: "2026-07-15", created: "Jul 15, 2026" },
  { id: "16", name: "Caleb Nwosu", initials: "CN", email: "caleb@hearthstone.dev", company: "Hearthstone", status: "active", plan: "Growth", mrr: 890, createdAt: "2025-05-18", created: "May 18, 2025" },
  { id: "17", name: "Sofia Reyes", initials: "SR", email: "sofia@cadence.io", company: "Cadence", status: "active", plan: "Scale", mrr: 3100, createdAt: "2025-02-11", created: "Feb 11, 2025" },
  { id: "18", name: "Yusuf Kaya", initials: "YK", email: "yusuf@windrose.app", company: "Windrose", status: "past_due", plan: "Scale", mrr: 2400, createdAt: "2026-03-04", created: "Mar 4, 2026" },
  { id: "19", name: "Greta Hoffmann", initials: "GH", email: "greta@almanac.co", company: "Almanac", status: "active", plan: "Enterprise", mrr: 9400, createdAt: "2024-06-27", created: "Jun 27, 2024" },
  { id: "20", name: "Theo Laurent", initials: "TL", email: "theo@basalt.dev", company: "Basalt", status: "active", plan: "Starter", mrr: 149, createdAt: "2026-05-09", created: "May 9, 2026" },
  { id: "21", name: "Maya Sundaram", initials: "MS", email: "maya@kindling.io", company: "Kindling", status: "canceled", plan: "Scale", mrr: 0, createdAt: "2025-07-13", created: "Jul 13, 2025" },
  { id: "22", name: "Finn Gallagher", initials: "FG", email: "finn@overstory.com", company: "Overstory", status: "active", plan: "Growth", mrr: 1180, createdAt: "2026-04-26", created: "Apr 26, 2026" },
  { id: "23", name: "Zara Haddad", initials: "ZH", email: "zara@lanternfish.co", company: "Lanternfish", status: "trialing", plan: "Enterprise", mrr: 0, createdAt: "2026-08-03", created: "Aug 3, 2026" },
];

// ---------------------------------------------------------------------------
// Presentation helpers
// ---------------------------------------------------------------------------

const STATUS_META: Record<
  CustomerStatus,
  { label: string; variant: BadgeProps["variant"] }
> = {
  active: { label: "Active", variant: "success" },
  trialing: { label: "Trialing", variant: "info" },
  past_due: { label: "Past due", variant: "warning" },
  canceled: { label: "Canceled", variant: "secondary" },
};

const STATUS_FILTERS = [
  { label: "All statuses", value: "all" },
  { label: "Active", value: "active" },
  { label: "Trialing", value: "trialing" },
  { label: "Past due", value: "past_due" },
  { label: "Canceled", value: "canceled" },
];

const PLAN_FILTERS = [
  { label: "All plans", value: "all" },
  { label: "Starter", value: "Starter" },
  { label: "Growth", value: "Growth" },
  { label: "Scale", value: "Scale" },
  { label: "Enterprise", value: "Enterprise" },
];

const PAGE_SIZES = [
  { label: "10", value: "10" },
  { label: "25", value: "25" },
  { label: "50", value: "50" },
];

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

type SortKey = "name" | "mrr" | "createdAt";
type SortDir = "asc" | "desc";

/**
 * Page numbers to render, collapsing long runs to an ellipsis.
 * Always shows the first page, the last page, and a window around the current.
 */
function pageWindow(current: number, total: number): (number | "gap")[] {
  if (total <= 7) {
    return Array.from({ length: total }, (_, i) => i + 1);
  }

  const pages = new Set([1, total, current, current - 1, current + 1]);
  const visible = [...pages].filter((p) => p >= 1 && p <= total).sort((a, b) => a - b);

  const out: (number | "gap")[] = [];
  let previous = 0;
  for (const page of visible) {
    if (previous && page - previous > 1) out.push("gap");
    out.push(page);
    previous = page;
  }
  return out;
}

// ---------------------------------------------------------------------------
// Sortable column header
// ---------------------------------------------------------------------------

function SortHeader({
  active,
  dir,
  label,
  onToggle,
}: {
  active: boolean;
  dir: SortDir;
  label: string;
  onToggle: () => void;
}) {
  const Icon = !active ? ChevronsUpDownIcon : dir === "asc" ? ArrowUpIcon : ArrowDownIcon;

  return (
    <button
      aria-label={`Sort by ${label}`}
      className="-mx-1 inline-flex items-center gap-1.5 rounded-sm px-1 py-1 outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-active:text-foreground"
      data-active={active || undefined}
      onClick={onToggle}
      type="button"
    >
      {label}
      <Icon className="size-3.5 shrink-0 opacity-60" />
    </button>
  );
}

// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------

export default function TableFilters() {
  const [query, setQuery] = useState("");
  const [status, setStatus] = useState("all");
  const [plan, setPlan] = useState("all");
  const [sortKey, setSortKey] = useState<SortKey>("createdAt");
  const [sortDir, setSortDir] = useState<SortDir>("desc");
  const [pageSize, setPageSize] = useState(10);
  const [page, setPage] = useState(1);

  const isFiltered = query !== "" || status !== "all" || plan !== "all";

  function resetFilters() {
    setQuery("");
    setStatus("all");
    setPlan("all");
    setPage(1);
  }

  function toggleSort(key: SortKey) {
    if (key === sortKey) {
      setSortDir((d) => (d === "asc" ? "desc" : "asc"));
    } else {
      setSortKey(key);
      setSortDir(key === "name" ? "asc" : "desc");
    }
    setPage(1);
  }

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    return customers.filter((customer) => {
      const matchesQuery =
        q === "" ||
        customer.name.toLowerCase().includes(q) ||
        customer.email.toLowerCase().includes(q) ||
        customer.company.toLowerCase().includes(q);
      const matchesStatus = status === "all" || customer.status === status;
      const matchesPlan = plan === "all" || customer.plan === plan;
      return matchesQuery && matchesStatus && matchesPlan;
    });
  }, [plan, query, status]);

  const sorted = useMemo(() => {
    const rows = [...filtered];
    rows.sort((a, b) => {
      const cmp =
        sortKey === "mrr"
          ? a.mrr - b.mrr
          : sortKey === "createdAt"
            ? a.createdAt.localeCompare(b.createdAt)
            : a.name.localeCompare(b.name);
      return sortDir === "asc" ? cmp : -cmp;
    });
    return rows;
  }, [filtered, sortDir, sortKey]);

  // Clamp rather than reset in an effect: if filtering shrinks the result set
  // below the current page, fall back to the last page that still has rows.
  const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));
  const currentPage = Math.min(page, pageCount);
  const start = (currentPage - 1) * pageSize;
  const rows = sorted.slice(start, start + pageSize);
  const filteredMrr = sorted.reduce((sum, customer) => sum + customer.mrr, 0);

  return (
    <div className="flex flex-col gap-6 p-4 sm:p-6 md:p-8 [&_[data-slot=button]]:transition-[box-shadow,scale] [&_[data-slot=button]:not(.w-full)]:motion-safe:hover:scale-[1.05] [&_[data-slot=button].w-full]:motion-safe:hover:scale-[1.03]">
      {/* Page heading */}
      <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
        <div className="flex flex-col gap-1">
          <h1 className="text-2xl font-semibold tracking-tight">Customers</h1>
          <p className="text-sm text-muted-foreground">
            {customers.length} accounts across all workspaces
          </p>
        </div>
        <div className="flex items-center gap-2">
          <Button size="sm" variant="outline">
            <DownloadIcon />
            Export
          </Button>
          <Button size="sm">
            <PlusIcon />
            Add customer
          </Button>
        </div>
      </div>

      {/* Toolbar — search + faceted filters */}
      <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
        <InputGroup className="sm:max-w-xs">
          <InputGroupAddon>
            <SearchIcon />
          </InputGroupAddon>
          <InputGroupInput
            aria-label="Search customers"
            onValueChange={(value) => {
              setQuery(value);
              setPage(1);
            }}
            placeholder="Search name, email, or company"
            value={query}
          />
        </InputGroup>

        <div className="flex flex-wrap items-center gap-2">
          <Select
            items={STATUS_FILTERS}
            onValueChange={(value) => {
              setStatus(value ?? "all");
              setPage(1);
            }}
            value={status}
          >
            {/* Pointer cursor is a house rule; coss ships `cursor-default`
                here by native-menu convention. It goes on the popup — which
                portals out of this block, so no ancestor rule can reach it —
                and never by editing coss source. */}
            <SelectTrigger className="w-auto min-w-36 cursor-pointer" size="sm">
              <SelectValue />
            </SelectTrigger>
            <SelectPopup className="[&_[data-slot$='-item']]:cursor-pointer">
              {STATUS_FILTERS.map((item) => (
                <SelectItem key={item.value} value={item.value}>
                  {item.label}
                </SelectItem>
              ))}
            </SelectPopup>
          </Select>

          <Select
            items={PLAN_FILTERS}
            onValueChange={(value) => {
              setPlan(value ?? "all");
              setPage(1);
            }}
            value={plan}
          >
            <SelectTrigger className="w-auto min-w-32 cursor-pointer" size="sm">
              <SelectValue />
            </SelectTrigger>
            <SelectPopup className="[&_[data-slot$='-item']]:cursor-pointer">
              {PLAN_FILTERS.map((item) => (
                <SelectItem key={item.value} value={item.value}>
                  {item.label}
                </SelectItem>
              ))}
            </SelectPopup>
          </Select>

          {isFiltered && (
            <Button onClick={resetFilters} size="sm" variant="ghost">
              Reset
            </Button>
          )}
        </div>
      </div>

      {/* Table */}
      <Table variant="card">
        <TableHeader>
          <TableRow>
            {/* w-full lets this column absorb the leftover width, which in a
                table also makes it the one that gives way when space runs
                short — the cell's contents truncate instead of the whole
                table overflowing into a horizontal scroll. */}
            <TableHead className="w-full">
              <SortHeader
                active={sortKey === "name"}
                dir={sortDir}
                label="Customer"
                onToggle={() => toggleSort("name")}
              />
            </TableHead>
            <TableHead>Status</TableHead>
            <TableHead className="max-md:hidden">Plan</TableHead>
            <TableHead className="max-sm:hidden text-right">
              <SortHeader
                active={sortKey === "mrr"}
                dir={sortDir}
                label="MRR"
                onToggle={() => toggleSort("mrr")}
              />
            </TableHead>
            <TableHead className="max-lg:hidden">
              <SortHeader
                active={sortKey === "createdAt"}
                dir={sortDir}
                label="Created"
                onToggle={() => toggleSort("createdAt")}
              />
            </TableHead>
            <TableHead className="w-px">
              <span className="sr-only">Actions</span>
            </TableHead>
          </TableRow>
        </TableHeader>

        <TableBody>
          {rows.length === 0 ? (
            <TableRow>
              <TableCell className="whitespace-normal p-0" colSpan={6}>
                <Empty>
                  <EmptyHeader>
                    <EmptyMedia variant="icon">
                      <SearchXIcon />
                    </EmptyMedia>
                    <EmptyTitle>No customers found</EmptyTitle>
                    <EmptyDescription>
                      No accounts match your current search and filters. Try a
                      broader search or clear the filters to start over.
                    </EmptyDescription>
                  </EmptyHeader>
                  <EmptyContent>
                    <Button onClick={resetFilters} size="sm" variant="outline">
                      Clear filters
                    </Button>
                  </EmptyContent>
                </Empty>
              </TableCell>
            </TableRow>
          ) : (
            rows.map((customer) => {
              const statusMeta = STATUS_META[customer.status];
              return (
                <TableRow key={customer.id}>
                  <TableCell className="w-full">
                    <div className="flex items-center gap-3">
                      <Avatar className="size-8 shrink-0 rounded-full">
                        <AvatarFallback className="text-xs">
                          {customer.initials}
                        </AvatarFallback>
                      </Avatar>
                      <div className="flex min-w-0 flex-col gap-1">
                        <span className="truncate font-medium leading-none">
                          {customer.name}
                        </span>
                        <span className="truncate text-xs text-muted-foreground leading-none">
                          {customer.email}
                        </span>
                      </div>
                    </div>
                  </TableCell>
                  <TableCell>
                    <Badge variant={statusMeta.variant}>{statusMeta.label}</Badge>
                  </TableCell>
                  <TableCell className="max-md:hidden text-muted-foreground">
                    {customer.plan}
                  </TableCell>
                  <TableCell className="max-sm:hidden text-right tabular-nums">
                    {customer.mrr === 0 ? (
                      <span className="text-muted-foreground">—</span>
                    ) : (
                      currency.format(customer.mrr)
                    )}
                  </TableCell>
                  <TableCell className="max-lg:hidden text-muted-foreground">
                    {customer.created}
                  </TableCell>
                  <TableCell>
                    <Menu>
                      <MenuTrigger
                        render={
                          <Button
                            aria-label={`Actions for ${customer.name}`}
                            size="icon-sm"
                            variant="ghost"
                          />
                        }
                      >
                        <MoreHorizontalIcon />
                      </MenuTrigger>
                      <MenuPopup
                        align="end"
                        className="min-w-44 [&_[data-slot$='-item']]:cursor-pointer"
                      >
                        <MenuItem>
                          <UserRoundIcon />
                          View customer
                        </MenuItem>
                        <MenuItem>
                          <SquarePenIcon />
                          Edit details
                        </MenuItem>
                        <MenuItem>
                          <MailIcon />
                          Email customer
                        </MenuItem>
                        <MenuSeparator />
                        <MenuItem variant="destructive">
                          <Trash2Icon />
                          Delete customer
                        </MenuItem>
                      </MenuPopup>
                    </Menu>
                  </TableCell>
                </TableRow>
              );
            })
          )}
        </TableBody>

        {/* Hidden below sm alongside the MRR column it totals. */}
        {rows.length > 0 && (
          <TableFooter className="max-sm:hidden">
            {/* One cell per column, carrying the same responsive `hidden`
                classes as the body rows. colSpan would drift out of alignment
                as soon as a column is hidden, since display:none cells drop
                out of the table layout entirely. */}
            <TableRow>
              <TableCell className="font-normal text-muted-foreground">
                Total MRR{isFiltered ? " (filtered)" : ""}
              </TableCell>
              <TableCell />
              <TableCell className="max-md:hidden" />
              <TableCell className="text-right tabular-nums">
                {currency.format(filteredMrr)}
              </TableCell>
              <TableCell className="max-lg:hidden" />
              <TableCell />
            </TableRow>
          </TableFooter>
        )}
      </Table>

      {/* Footer — result count, page size, pagination */}
      <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
        <p className="text-sm text-muted-foreground">
          {sorted.length === 0
            ? "No results"
            : `Showing ${start + 1}–${start + rows.length} of ${sorted.length}`}
        </p>

        <div className="flex flex-wrap items-center gap-4">
          <div className="flex items-center gap-2">
            <span className="text-sm text-muted-foreground">Rows per page</span>
            <Select
              items={PAGE_SIZES}
              onValueChange={(value) => {
                setPageSize(Number(value ?? pageSize));
                setPage(1);
              }}
              value={String(pageSize)}
            >
              <SelectTrigger
                aria-label="Rows per page"
                className="w-auto min-w-0 cursor-pointer"
                size="sm"
              >
                <SelectValue />
              </SelectTrigger>
              <SelectPopup className="[&_[data-slot$='-item']]:cursor-pointer">
                {PAGE_SIZES.map((item) => (
                  <SelectItem key={item.value} value={item.value}>
                    {item.label}
                  </SelectItem>
                ))}
              </SelectPopup>
            </Select>
          </div>

          {/* Buttons rather than links: paging here is client-side state, so
              there is no URL to navigate to. */}
          <Pagination className="mx-0 w-auto justify-end">
            <PaginationContent>
              <PaginationItem>
                <Button
                  disabled={currentPage === 1}
                  onClick={() => setPage(currentPage - 1)}
                  size="sm"
                  variant="ghost"
                >
                  <ChevronLeftIcon />
                  <span className="max-sm:hidden">Previous</span>
                </Button>
              </PaginationItem>

              {pageWindow(currentPage, pageCount).map((entry, index) =>
                entry === "gap" ? (
                  <PaginationItem key={`gap-${index}`}>
                    <PaginationEllipsis />
                  </PaginationItem>
                ) : (
                  <PaginationItem key={entry}>
                    <Button
                      aria-current={entry === currentPage ? "page" : undefined}
                      aria-label={`Go to page ${entry}`}
                      onClick={() => setPage(entry)}
                      size="icon-sm"
                      variant={entry === currentPage ? "outline" : "ghost"}
                    >
                      {entry}
                    </Button>
                  </PaginationItem>
                ),
              )}

              <PaginationItem>
                <Button
                  disabled={currentPage === pageCount}
                  onClick={() => setPage(currentPage + 1)}
                  size="sm"
                  variant="ghost"
                >
                  <span className="max-sm:hidden">Next</span>
                  <ChevronRightIcon />
                </Button>
              </PaginationItem>
            </PaginationContent>
          </Pagination>
        </div>
      </div>
    </div>
  );
}

Dependencies

  • @coss/ui