This is an installable recipe with sample payment data, not a generic table component accepting arbitrary data and columns. Copy it, define your row type and columns, then connect application data. Use Table for a simple semantic table without state management.
The row menu copies the payment ID and reports success or failure beside that row. If clipboard access is unavailable, the message displays the ID for manual copying. Customer navigation and payment-detail screens are application responsibilities, not inactive menu entries in this recipe.
import DataTableDemo from "@/components/ui/data-table";
export default function DateTableDemo() {
return <DataTableDemo />;
}
#Installation
Complete the base setup. The CLI resolves the dependencies. For manual installation:
First time? Install the shared base once.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/data-table.jsonnpm install @tanstack/react-table@^8.21.3 lucide-react@^1.24.0Install Button, Checkbox, Dropdown Menu, Input, and Table. Create components/ui/data-table.tsx:
"use client";
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table";
import { ArrowUpDown, ChevronDown, MoreHorizontal } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const data: Payment[] = [
{
id: "m5gr84i9",
amount: 316,
status: "success",
email: "ken99@yahoo.com",
},
{
id: "3u1reuv4",
amount: 242,
status: "success",
email: "Abe45@gmail.com",
},
{
id: "derv1ws0",
amount: 837,
status: "processing",
email: "Monserrat44@gmail.com",
},
{
id: "5kma53ae",
amount: 874,
status: "success",
email: "Silas22@gmail.com",
},
{
id: "bhqecj4p",
amount: 721,
status: "failed",
email: "carmella@hotmail.com",
},
];
export type Payment = {
id: string;
amount: number;
status: "pending" | "processing" | "success" | "failed";
email: string;
};
function PaymentActions({ payment }: { payment: Payment }) {
const [copy, setCopy] = React.useState<"idle" | "pending" | "success" | "error">("idle");
async function copyId() {
setCopy("pending");
try {
await navigator.clipboard.writeText(payment.id);
setCopy("success");
} catch {
setCopy("error");
}
}
return (
<div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="noShadow"
className="size-8 p-0"
disabled={copy === "pending"}
aria-busy={copy === "pending" || undefined}
/>
}
>
<span className="sr-only">Open menu</span>
<MoreHorizontal />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={copyId}>Copy payment ID</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<output className="mt-1 block w-40 whitespace-normal text-xs" aria-live="polite">
{copy === "pending"
? "Copying ID…"
: copy === "success"
? "Copied payment ID."
: copy === "error"
? `Copy failed. Payment ID: ${payment.id}`
: null}
</output>
</div>
);
}
export const columns: ColumnDef<Payment>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={!table.getIsAllPageRowsSelected() && table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(value)}
aria-label="Select all"
className="size-8"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="size-8"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <div className="capitalize">{row.getValue("status")}</div>,
},
{
accessorKey: "email",
header: ({ column }) => {
return (
<Button
variant="noShadow"
size="sm"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Email
<ArrowUpDown />
</Button>
);
},
cell: ({ row }) => <div className="lowercase">{row.getValue("email")}</div>,
},
{
accessorKey: "amount",
header: () => <div className="text-right">Amount</div>,
cell: ({ row }) => {
const amount = parseFloat(row.getValue("amount"));
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(amount);
return <div className="text-right font-base">{formatted}</div>;
},
},
{
id: "actions",
enableHiding: false,
cell: ({ row }) => <PaymentActions payment={row.original} />,
},
];
export default function DataTableDemo() {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const table = useReactTable({
data,
columns,
getRowId: (payment) => payment.id,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="w-full font-base text-main-foreground">
<div className="flex flex-col items-stretch gap-3 py-4 sm:flex-row sm:items-center">
<Input
placeholder="Filter emails..."
aria-label="Filter email records"
value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn("email")?.setFilterValue(event.target.value)}
className="max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="noShadow" className="self-start sm:ml-auto sm:self-auto" />}
>
Columns <ChevronDown />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div>
<Table>
<TableHeader className="font-heading">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow className="bg-secondary-background text-foreground" key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const sort = header.column.getIsSorted();
return (
<TableHead
className="text-foreground"
key={header.id}
aria-sort={
sort === "asc" ? "ascending" : sort === "desc" ? "descending" : undefined
}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
className="bg-secondary-background text-foreground data-[state=selected]:bg-main data-[state=selected]:text-main-foreground"
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell className="px-4 py-3" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-start gap-3 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="text-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="noShadow"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="noShadow"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
);
}
#Usage
import DataTable from "@/components/ui/data-table";
export default function PaymentsExample() {
return <DataTable />;
}The default export initially renders the bundled example. Replace its data and columns definitions in the installed file; do not pass unsupported data or columns props to this export.
#API reference
| Recipe part | Purpose | Initial behavior |
|---|---|---|
DataTable | Default exported component without generic props | Sample payments |
data / columns | Row model and column definitions in source | Payment schema |
sorting / columnFilters | Client sorting and filtering | Empty state |
rowSelection / columnVisibility | Selected and hidden rows/columns | Empty state |
useReactTable | Row models and state callbacks | Configured locally |
getPaginationRowModel | Client pagination | TanStack default page size |
#Accessibility
Preserve semantic table elements, meaningful column headers, and names on row-selection controls. Sorting exposes its direction with aria-sort. Row actions announce their result without depending on a toast provider. Keep empty, loading, and error states distinct. The sample uses payment IDs as stable row IDs; configure your own stable IDs and server state for large or server-paginated datasets. This sample is not a database grid or backend integration.