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-filtersRequires the @cosskit namespace in your components.json.
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