All components
Filterable Data Table
tablesOrigin UI component.
responsive · 600px
Install
Same command in any shadcn project — React (Vite/CRA), Next.js, Remix, Astro, and more:
$
npx shadcn@latest add https://your-domain/r/comp-485.jsonUsage
import Cmp from "@/registry/origin-ui/comp-485";
export default function Demo() {
return <Cmp />;
}Component source
"use client";
import {
type ColumnDef,
type ColumnFiltersState,
type FilterFn,
flexRender,
getCoreRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type Row,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import {
ChevronDownIcon,
ChevronFirstIcon,
ChevronLastIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronUpIcon,
CircleAlertIcon,
CircleXIcon,
Columns3Icon,
EllipsisIcon,
FilterIcon,
ListFilterIcon,
PlusIcon,
TrashIcon,
} from "lucide-react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Pagination,
PaginationContent,
PaginationItem,
} from "@/components/ui/pagination";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type Item = {
id: string;
name: string;
email: string;
location: string;
flag: string;
status: "Active" | "Inactive" | "Pending";
balance: number;
};
// Custom filter function for multi-column searching
const multiColumnFilterFn: FilterFn<Item> = (row, _columnId, filterValue) => {
const searchableRowContent =
`${row.original.name} ${row.original.email}`.toLowerCase();
const searchTerm = (filterValue ?? "").toLowerCase();
return searchableRowContent.includes(searchTerm);
};
const statusFilterFn: FilterFn<Item> = (
row,
columnId,
filterValue: string[],
) => {
if (!filterValue?.length) return true;
const status = row.getValue(columnId) as string;
return filterValue.includes(status);
};
const columns: ColumnDef<Item>[] = [
{
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
),
enableHiding: false,
enableSorting: false,
header: ({ table }) => (
<Checkbox
aria-label="Select all"
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
/>
),
id: "select",
size: 28,
},
{
accessorKey: "name",
cell: ({ row }) => (
<div className="font-medium">{row.getValue("name")}</div>
),
enableHiding: false,
filterFn: multiColumnFilterFn,
header: "Name",
size: 180,
},
{
accessorKey: "email",
header: "Email",
size: 220,
},
{
accessorKey: "location",
cell: ({ row }) => (
<div>
<span className="text-lg leading-none">{row.original.flag}</span>{" "}
{row.getValue("location")}
</div>
),
header: "Location",
size: 180,
},
{
accessorKey: "status",
cell: ({ row }) => (
<Badge
className={cn(
row.getValue("status") === "Inactive" &&
"bg-muted-foreground/60 text-primary-foreground",
)}
>
{row.getValue("status")}
</Badge>
),
filterFn: statusFilterFn,
header: "Status",
size: 100,
},
{
accessorKey: "performance",
header: "Performance",
},
{
accessorKey: "balance",
cell: ({ row }) => {
const amount = Number.parseFloat(row.getValue("balance"));
const formatted = new Intl.NumberFormat("en-US", {
currency: "USD",
style: "currency",
}).format(amount);
return formatted;
},
header: "Balance",
size: 120,
},
{
cell: ({ row }) => <RowActions row={row} />,
enableHiding: false,
header: () => <span className="sr-only">Actions</span>,
id: "actions",
size: 60,
},
];
export default function Component() {
const id = useId();
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const inputRef = useRef<HTMLInputElement>(null);
const [sorting, setSorting] = useState<SortingState>([
{
desc: false,
id: "name",
},
]);
const [data, setData] = useState<Item[]>([]);
useEffect(() => {
async function fetchPosts() {
const res = await fetch(
"https://raw.githubusercontent.com/origin-space/origin-images/refs/heads/main/users-01_fertyx.json",
);
const data = await res.json();
setData(data);
}
fetchPosts();
}, []);
const handleDeleteRows = () => {
const selectedRows = table.getSelectedRowModel().rows;
const updatedData = data.filter(
(item) => !selectedRows.some((row) => row.original.id === item.id),
);
setData(updatedData);
table.resetRowSelection();
};
const table = useReactTable({
columns,
data,
enableSortingRemoval: false,
getCoreRowModel: getCoreRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onSortingChange: setSorting,
state: {
columnFilters,
columnVisibility,
pagination,
sorting,
},
});
// Get unique status values
const uniqueStatusValues = useMemo(() => {
const statusColumn = table.getColumn("status");
if (!statusColumn) return [];
const values = Array.from(statusColumn.getFacetedUniqueValues().keys());
return values.sort();
}, [table.getColumn]);
// Get counts for each status
const statusCounts = useMemo(() => {
const statusColumn = table.getColumn("status");
if (!statusColumn) return new Map();
return statusColumn.getFacetedUniqueValues();
}, [table]);
const selectedStatuses = useMemo(() => {
const filterValue = table.getColumn("status")?.getFilterValue() as string[];
return filterValue ?? [];
}, [table]);
const handleStatusChange = (checked: boolean, value: string) => {
const filterValue = table.getColumn("status")?.getFilterValue() as string[];
const newFilterValue = filterValue ? [...filterValue] : [];
if (checked) {
newFilterValue.push(value);
} else {
const index = newFilterValue.indexOf(value);
if (index > -1) {
newFilterValue.splice(index, 1);
}
}
table
.getColumn("status")
?.setFilterValue(newFilterValue.length ? newFilterValue : undefined);
};
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
{/* Filter by name or email */}
<div className="relative">
<Input
aria-label="Filter by name or email"
className={cn(
"peer min-w-60 ps-9",
Boolean(table.getColumn("name")?.getFilterValue()) && "pe-9",
)}
id={`${id}-input`}
onChange={(e) =>
table.getColumn("name")?.setFilterValue(e.target.value)
}
placeholder="Filter by name or email..."
ref={inputRef}
type="text"
value={
(table.getColumn("name")?.getFilterValue() ?? "") as string
}
/>
<div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50">
<ListFilterIcon aria-hidden="true" size={16} />
</div>
{Boolean(table.getColumn("name")?.getFilterValue()) && (
<button
aria-label="Clear filter"
className="absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-md text-muted-foreground/80 outline-none transition-[color,box-shadow] hover:text-foreground focus:z-10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => {
table.getColumn("name")?.setFilterValue("");
if (inputRef.current) {
inputRef.current.focus();
}
}}
type="button"
>
<CircleXIcon aria-hidden="true" size={16} />
</button>
)}
</div>
{/* Filter by status */}
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">
<FilterIcon
aria-hidden="true"
className="-ms-1 opacity-60"
size={16}
/>
Status
{selectedStatuses.length > 0 && (
<span className="-me-1 inline-flex h-5 max-h-full items-center rounded border bg-background px-1 font-[inherit] font-medium text-[0.625rem] text-muted-foreground/70">
{selectedStatuses.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto min-w-36 p-3">
<div className="space-y-3">
<div className="font-medium text-muted-foreground text-xs">
Filters
</div>
<div className="space-y-3">
{uniqueStatusValues.map((value, i) => (
<div className="flex items-center gap-2" key={value}>
<Checkbox
checked={selectedStatuses.includes(value)}
id={`${id}-${i}`}
onCheckedChange={(checked: boolean) =>
handleStatusChange(checked, value)
}
/>
<Label
className="flex grow justify-between gap-2 font-normal"
htmlFor={`${id}-${i}`}
>
{value}{" "}
<span className="ms-2 text-muted-foreground text-xs">
{statusCounts.get(value)}
</span>
</Label>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
{/* Toggle columns visibility */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<Columns3Icon
aria-hidden="true"
className="-ms-1 opacity-60"
size={16}
/>
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
checked={column.getIsVisible()}
className="capitalize"
key={column.id}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
onSelect={(event) => event.preventDefault()}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-3">
{/* Delete button */}
{table.getSelectedRowModel().rows.length > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="ml-auto" variant="outline">
<TrashIcon
aria-hidden="true"
className="-ms-1 opacity-60"
size={16}
/>
Delete
<span className="-me-1 inline-flex h-5 max-h-full items-center rounded border bg-background px-1 font-[inherit] font-medium text-[0.625rem] text-muted-foreground/70">
{table.getSelectedRowModel().rows.length}
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<div className="flex flex-col gap-2 max-sm:items-center sm:flex-row sm:gap-4">
<div
aria-hidden="true"
className="flex size-9 shrink-0 items-center justify-center rounded-full border"
>
<CircleAlertIcon className="opacity-80" size={16} />
</div>
<AlertDialogHeader>
<AlertDialogTitle>
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete{" "}
{table.getSelectedRowModel().rows.length} selected{" "}
{table.getSelectedRowModel().rows.length === 1
? "row"
: "rows"}
.
</AlertDialogDescription>
</AlertDialogHeader>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteRows}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{/* Add user button */}
<Button className="ml-auto" variant="outline">
<PlusIcon
aria-hidden="true"
className="-ms-1 opacity-60"
size={16}
/>
Add user
</Button>
</div>
</div>
{/* Table */}
<div className="overflow-hidden rounded-md border bg-background">
<Table className="table-fixed">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow className="hover:bg-transparent" key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
className="h-11"
key={header.id}
style={{ width: `${header.getSize()}px` }}
>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<div
className={cn(
header.column.getCanSort() &&
"flex h-full cursor-pointer select-none items-center justify-between gap-2",
)}
onClick={header.column.getToggleSortingHandler()}
onKeyDown={(e) => {
// Enhanced keyboard handling for sorting
if (
header.column.getCanSort() &&
(e.key === "Enter" || e.key === " ")
) {
e.preventDefault();
header.column.getToggleSortingHandler()?.(e);
}
}}
tabIndex={header.column.getCanSort() ? 0 : undefined}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{{
asc: (
<ChevronUpIcon
aria-hidden="true"
className="shrink-0 opacity-60"
size={16}
/>
),
desc: (
<ChevronDownIcon
aria-hidden="true"
className="shrink-0 opacity-60"
size={16}
/>
),
}[header.column.getIsSorted() as string] ?? null}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
data-state={row.getIsSelected() && "selected"}
key={row.id}
>
{row.getVisibleCells().map((cell) => (
<TableCell className="last:py-0" key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
className="h-24 text-center"
colSpan={columns.length}
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between gap-8">
{/* Results per page */}
<div className="flex items-center gap-3">
<Label className="max-sm:sr-only" htmlFor={id}>
Rows per page
</Label>
<Select
onValueChange={(value) => {
table.setPageSize(Number(value));
}}
value={table.getState().pagination.pageSize.toString()}
>
<SelectTrigger className="w-fit whitespace-nowrap" id={id}>
<SelectValue placeholder="Select number of results" />
</SelectTrigger>
<SelectContent className="[&_*[role=option]>span]:start-auto [&_*[role=option]>span]:end-2 [&_*[role=option]]:ps-2 [&_*[role=option]]:pe-8">
{[5, 10, 25, 50].map((pageSize) => (
<SelectItem key={pageSize} value={pageSize.toString()}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Page number information */}
<div className="flex grow justify-end whitespace-nowrap text-muted-foreground text-sm">
<p
aria-live="polite"
className="whitespace-nowrap text-muted-foreground text-sm"
>
<span className="text-foreground">
{table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
1}
-
{Math.min(
Math.max(
table.getState().pagination.pageIndex *
table.getState().pagination.pageSize +
table.getState().pagination.pageSize,
0,
),
table.getRowCount(),
)}
</span>{" "}
of{" "}
<span className="text-foreground">
{table.getRowCount().toString()}
</span>
</p>
</div>
{/* Pagination buttons */}
<div>
<Pagination>
<PaginationContent>
{/* First page button */}
<PaginationItem>
<Button
aria-label="Go to first page"
className="disabled:pointer-events-none disabled:opacity-50"
disabled={!table.getCanPreviousPage()}
onClick={() => table.firstPage()}
size="icon"
variant="outline"
>
<ChevronFirstIcon aria-hidden="true" size={16} />
</Button>
</PaginationItem>
{/* Previous page button */}
<PaginationItem>
<Button
aria-label="Go to previous page"
className="disabled:pointer-events-none disabled:opacity-50"
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
size="icon"
variant="outline"
>
<ChevronLeftIcon aria-hidden="true" size={16} />
</Button>
</PaginationItem>
{/* Next page button */}
<PaginationItem>
<Button
aria-label="Go to next page"
className="disabled:pointer-events-none disabled:opacity-50"
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
size="icon"
variant="outline"
>
<ChevronRightIcon aria-hidden="true" size={16} />
</Button>
</PaginationItem>
{/* Last page button */}
<PaginationItem>
<Button
aria-label="Go to last page"
className="disabled:pointer-events-none disabled:opacity-50"
disabled={!table.getCanNextPage()}
onClick={() => table.lastPage()}
size="icon"
variant="outline"
>
<ChevronLastIcon aria-hidden="true" size={16} />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
<p className="mt-4 text-center text-muted-foreground text-sm">
Example of a more complex table made with{" "}
<a
className="underline hover:text-foreground"
href="https://tanstack.com/table"
rel="noopener noreferrer"
target="_blank"
>
TanStack Table
</a>
</p>
</div>
);
}
function RowActions({ row: _row }: { row: Row<Item> }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex justify-end">
<Button
aria-label="Edit item"
className="shadow-none"
size="icon"
variant="ghost"
>
<EllipsisIcon aria-hidden="true" size={16} />
</Button>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem>
<span>Edit</span>
<DropdownMenuShortcut>⌘E</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<span>Duplicate</span>
<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<span>Archive</span>
<DropdownMenuShortcut>⌘A</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>More</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem>Move to project</DropdownMenuItem>
<DropdownMenuItem>Move to folder</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Advanced options</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>Share</DropdownMenuItem>
<DropdownMenuItem>Add to favorites</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
<span>Delete</span>
<DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}Dependencies
@tanstack/react-table
Source: Origin UI