Components
Table
Table
Ultra-modern data table component with sorting, filtering, pagination, selection, and advanced data management features.
Basic Table
Name | Email | Role | Department | Status | Last Login |
|---|---|---|---|---|---|
| John Doe | john@example.com | Admin | Engineering | active | 1/15/2024 |
| Jane Smith | jane@example.com | User | Marketing | active | 1/14/2024 |
| Bob Johnson | bob@example.com | User | Sales | inactive | 1/10/2024 |
Sortable Table
Name | Email | Role | Department | Status | Last Login |
|---|---|---|---|---|---|
| John Doe | john@example.com | Admin | Engineering | active | 1/15/2024 |
| Jane Smith | jane@example.com | User | Marketing | active | 1/14/2024 |
| Bob Johnson | bob@example.com | User | Sales | inactive | 1/10/2024 |
| Alice Brown | alice@example.com | Moderator | Support | active | 1/13/2024 |
| Charlie Wilson | charlie@example.com | User | Engineering | active | 1/12/2024 |
| Diana Prince | diana@example.com | Admin | HR | inactive | 1/8/2024 |
| Eve Garcia | eve@example.com | User | Finance | active | 1/11/2024 |
| Frank Miller | frank@example.com | Moderator | Engineering | active | 1/9/2024 |
Filterable Table
Name | Email | Role | Department | Status | Last Login |
|---|---|---|---|---|---|
| John Doe | john@example.com | Admin | Engineering | active | 1/15/2024 |
| Jane Smith | jane@example.com | User | Marketing | active | 1/14/2024 |
| Bob Johnson | bob@example.com | User | Sales | inactive | 1/10/2024 |
| Alice Brown | alice@example.com | Moderator | Support | active | 1/13/2024 |
| Charlie Wilson | charlie@example.com | User | Engineering | active | 1/12/2024 |
| Diana Prince | diana@example.com | Admin | HR | inactive | 1/8/2024 |
| Eve Garcia | eve@example.com | User | Finance | active | 1/11/2024 |
| Frank Miller | frank@example.com | Moderator | Engineering | active | 1/9/2024 |
Selectable Table
Name | Email | Role | Department | Status | Last Login | |
|---|---|---|---|---|---|---|
| John Doe | john@example.com | Admin | Engineering | active | 1/15/2024 | |
| Jane Smith | jane@example.com | User | Marketing | active | 1/14/2024 | |
| Bob Johnson | bob@example.com | User | Sales | inactive | 1/10/2024 | |
| Alice Brown | alice@example.com | Moderator | Support | active | 1/13/2024 | |
| Charlie Wilson | charlie@example.com | User | Engineering | active | 1/12/2024 | |
| Diana Prince | diana@example.com | Admin | HR | inactive | 1/8/2024 | |
| Eve Garcia | eve@example.com | User | Finance | active | 1/11/2024 | |
| Frank Miller | frank@example.com | Moderator | Engineering | active | 1/9/2024 |
Paginated Table
Name | Email | Role | Department | Status | Last Login |
|---|---|---|---|---|---|
| John Doe | john@example.com | Admin | Engineering | active | 1/15/2024 |
| Jane Smith | jane@example.com | User | Marketing | active | 1/14/2024 |
| Bob Johnson | bob@example.com | User | Sales | inactive | 1/10/2024 |
Showing 1 to 3 of 8 entries
Products Table (Bordered)
Product | Category | Price | Stock | Rating | Status | |
|---|---|---|---|---|---|---|
| Wireless Headphones | Electronics | $199.99 | 45 | ★4.5 | In Stock | |
| Smart Watch | Wearables | $299.99 | 23 | ★4.2 | In Stock | |
| Laptop Stand | Accessories | $49.99 | 0 | ★4 | Out of Stock | |
| USB-C Cable | Accessories | $19.99 | 156 | ★4.8 | In Stock | |
| Bluetooth Speaker | Electronics | $79.99 | 12 | ★4.3 | Low Stock |
Compact Table
Name | Email | Role | Department | Status | Last Login |
|---|---|---|---|---|---|
| John Doe | john@example.com | Admin | Engineering | active | 1/15/2024 |
| Jane Smith | jane@example.com | User | Marketing | active | 1/14/2024 |
| Bob Johnson | bob@example.com | User | Sales | inactive | 1/10/2024 |
| Alice Brown | alice@example.com | Moderator | Support | active | 1/13/2024 |
Loading State
Name | Email | Role | Department | Status | Last Login |
|---|---|---|---|---|---|
Loading... | |||||
Empty State
Name | Email | Role | Department | Status | Last Login |
|---|---|---|---|---|---|
| No users found. Try adjusting your search criteria. | |||||
Advanced Features Demo
Complete User Management System
This table demonstrates all features working together: sorting, filtering, selection, pagination, and custom rendering.
Name | Email | Role | Department | Status | Last Login | |
|---|---|---|---|---|---|---|
| John Doe | john@example.com | Admin | Engineering | active | 1/15/2024 | |
| Jane Smith | jane@example.com | User | Marketing | active | 1/14/2024 | |
| Bob Johnson | bob@example.com | User | Sales | inactive | 1/10/2024 | |
| Alice Brown | alice@example.com | Moderator | Support | active | 1/13/2024 | |
| Charlie Wilson | charlie@example.com | User | Engineering | active | 1/12/2024 |
Showing 1 to 5 of 8 entries
Keyboard Navigation
Try these keyboard shortcuts:
- Tab: Move focus between table elements
- Space: Select/deselect rows (when selectable)
- Enter: Trigger row click action
- Arrow Keys: Navigate through sortable columns
- Ctrl+A: Select all rows (when selectable)
Name | Email | Role | Department | Status | Last Login | |
|---|---|---|---|---|---|---|
| John Doe | john@example.com | Admin | Engineering | active | 1/15/2024 | |
| Jane Smith | jane@example.com | User | Marketing | active | 1/14/2024 | |
| Bob Johnson | bob@example.com | User | Sales | inactive | 1/10/2024 |
Installation
1
Install the packages
npm i motion clsx tailwind-merge lucide-react2
Add util file
lib/util.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
3
Copy and paste the following code into your project
table.tsx
"use client";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useMemo } from "react";
import { cn } from "@/lib/utils";
import {
ChevronUp,
ChevronDown,
ChevronsUpDown,
Search,
MoreHorizontal,
Loader2,
Check,
Minus
} from "lucide-react";
interface Column<T> {
header: string;
accessor: keyof T | string;
sortable?: boolean;
filterable?: boolean;
width?: string;
render?: (value: any, row: T, index: number) => React.ReactNode;
className?: string;
}
interface PaginationConfig {
pageSize?: number;
showSizeChanger?: boolean;
showQuickJumper?: boolean;
showTotal?: (total: number, range: [number, number]) => string;
}
interface TableProps<T> {
data: T[];
columns: Column<T>[];
loading?: boolean;
sortable?: boolean;
filterable?: boolean;
selectable?: boolean;
pagination?: boolean | PaginationConfig;
striped?: boolean;
bordered?: boolean;
compact?: boolean;
emptyMessage?: string;
onRowClick?: (row: T, index: number) => void;
onSelectionChange?: (selectedRows: T[]) => void;
onSort?: (column: string, direction: 'asc' | 'desc') => void;
className?: string;
}
function Table<T extends Record<string, any>>({
data,
columns,
loading = false,
sortable = true,
filterable = false,
selectable = false,
pagination = false,
striped = false,
bordered = false,
compact = false,
emptyMessage = "No data available",
onRowClick,
onSelectionChange,
onSort,
className,
}: TableProps<T>) {
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [searchTerm, setSearchTerm] = useState('');
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const pageSize = typeof pagination === 'object' ? pagination.pageSize || 10 : 10;
const showPagination = Boolean(pagination);
// Filter and sort data
const processedData = useMemo(() => {
let filtered = data;
// Apply search filter
if (searchTerm && filterable) {
filtered = data.filter(row =>
columns.some(col => {
const value = row[col.accessor as keyof T];
return String(value).toLowerCase().includes(searchTerm.toLowerCase());
})
);
}
// Apply sorting
if (sortColumn && sortable) {
filtered = [...filtered].sort((a, b) => {
const aValue = a[sortColumn as keyof T];
const bValue = b[sortColumn as keyof T];
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
}
return filtered;
}, [data, searchTerm, sortColumn, sortDirection, columns, filterable, sortable]);
// Paginate data
const paginatedData = useMemo(() => {
if (!showPagination) return processedData;
const startIndex = (currentPage - 1) * pageSize;
return processedData.slice(startIndex, startIndex + pageSize);
}, [processedData, currentPage, pageSize, showPagination]);
const totalPages = Math.ceil(processedData.length / pageSize);
// Handle sorting
const handleSort = (column: string) => {
if (!sortable) return;
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
onSort?.(column, sortDirection === 'asc' ? 'desc' : 'asc');
};
// Handle row selection
const handleRowSelect = (index: number, checked: boolean) => {
const newSelected = new Set(selectedRows);
if (checked) {
newSelected.add(index);
} else {
newSelected.delete(index);
}
setSelectedRows(newSelected);
onSelectionChange?.(Array.from(newSelected).map(i => paginatedData[i]));
};
// Handle select all
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allIndices = paginatedData.map((_, index) => index);
setSelectedRows(new Set(allIndices));
onSelectionChange?.(paginatedData);
} else {
setSelectedRows(new Set());
onSelectionChange?.([]);
}
};
// Check if all rows are selected
const allSelected = paginatedData.length > 0 && selectedRows.size === paginatedData.length;
const someSelected = selectedRows.size > 0 && selectedRows.size < paginatedData.length;
return (
<div className={cn("w-full", className)}>
{/* Search */}
{filterable && (
<motion.div
className="mb-4 relative"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:border-gray-600 dark:text-white"
/>
</motion.div>
)}
{/* Table */}
<motion.div
className="overflow-hidden border border-gray-200 rounded-lg dark:border-gray-700"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
<div className="overflow-x-auto">
<table className="w-full">
{/* Table Header */}
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{/* Selection Column */}
{selectable && (
<th className="px-4 py-3 text-left">
<div className="flex items-center">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={(e) => handleSelectAll(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
</th>
)}
{/* Data Columns */}
{columns.map((column, index) => (
<th
key={index}
className={cn(
"px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider",
column.sortable && sortable && "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700",
column.className
)}
style={{ width: column.width }}
onClick={() => column.sortable && handleSort(column.accessor as string)}
>
<div className="flex items-center gap-2">
<span>{column.header}</span>
{/* Sort Indicator */}
{column.sortable && sortable && (
<motion.div
animate={{
rotate: sortColumn === column.accessor
? sortDirection === 'asc' ? 0 : 180
: 0
}}
transition={{ duration: 0.2 }}
>
{sortColumn === column.accessor ? (
sortDirection === 'asc' ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)
) : (
<ChevronsUpDown className="w-4 h-4 opacity-50" />
)}
</motion.div>
)}
</div>
</th>
))}
</tr>
</thead>
{/* Table Body */}
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{/* Loading State */}
{loading && (
<tr>
<td
colSpan={columns.length + (selectable ? 1 : 0)}
className="px-4 py-8 text-center"
>
<div className="flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
<span className="ml-2 text-gray-500">Loading...</span>
</div>
</td>
</tr>
)}
{/* Empty State */}
{!loading && paginatedData.length === 0 && (
<tr>
<td
colSpan={columns.length + (selectable ? 1 : 0)}
className="px-4 py-8 text-center text-gray-500"
>
{emptyMessage}
</td>
</tr>
)}
{/* Data Rows */}
{!loading && paginatedData.map((row, index) => {
const actualIndex = (currentPage - 1) * pageSize + index;
const isSelected = selectedRows.has(index);
return (
<motion.tr
key={actualIndex}
className={cn(
"hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-200",
striped && index % 2 === 1 && "bg-gray-50 dark:bg-gray-800",
isSelected && "bg-blue-50 dark:bg-blue-950",
onRowClick && "cursor-pointer"
)}
onClick={() => onRowClick?.(row, actualIndex)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
>
{/* Selection Column */}
{selectable && (
<td className="px-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => handleRowSelect(index, e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{/* Data Columns */}
{columns.map((column, colIndex) => {
const value = row[column.accessor as keyof T];
const renderedValue = column.render
? column.render(value, row, actualIndex)
: value;
return (
<td
key={colIndex}
className={cn(
"px-4 py-3 text-sm text-gray-900 dark:text-white",
compact ? "py-2" : "py-3",
bordered && "border border-gray-200 dark:border-gray-700",
column.className
)}
>
{renderedValue}
</td>
);
})}
</motion.tr>
);
})}
</tbody>
</table>
</div>
</motion.div>
{/* Pagination */}
{showPagination && totalPages > 1 && (
<motion.div
className="flex items-center justify-between mt-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<div className="text-sm text-gray-700 dark:text-gray-300">
Showing {(currentPage - 1) * pageSize + 1} to {Math.min(currentPage * pageSize, processedData.length)} of {processedData.length} entries
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:hover:bg-gray-800"
>
Previous
</button>
{/* Page Numbers */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = Math.max(1, Math.min(totalPages - 4, currentPage - 2)) + i;
if (pageNum > totalPages) return null;
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={cn(
"px-3 py-1 text-sm border rounded",
pageNum === currentPage
? "bg-blue-500 text-white border-blue-500"
: "border-gray-300 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
)}
>
{pageNum}
</button>
);
})}
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:hover:bg-gray-800"
>
Next
</button>
</div>
</motion.div>
)}
{/* Selection Summary */}
{selectable && selectedRows.size > 0 && (
<motion.div
className="mt-4 p-3 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-center justify-between">
<span className="text-sm text-blue-700 dark:text-blue-300">
{selectedRows.size} row{selectedRows.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={() => {
setSelectedRows(new Set());
onSelectionChange?.([]);
}}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
>
Clear selection
</button>
</div>
</motion.div>
)}
</div>
);
}
export default Table;"use client";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useMemo } from "react";
import { cn } from "@/lib/utils";
import {
ChevronUp,
ChevronDown,
ChevronsUpDown,
Search,
MoreHorizontal,
Loader2,
Check,
Minus
} from "lucide-react";
interface Column<T> {
header: string;
accessor: keyof T | string;
sortable?: boolean;
filterable?: boolean;
width?: string;
render?: (value: any, row: T, index: number) => React.ReactNode;
className?: string;
}
interface PaginationConfig {
pageSize?: number;
showSizeChanger?: boolean;
showQuickJumper?: boolean;
showTotal?: (total: number, range: [number, number]) => string;
}
interface TableProps<T> {
data: T[];
columns: Column<T>[];
loading?: boolean;
sortable?: boolean;
filterable?: boolean;
selectable?: boolean;
pagination?: boolean | PaginationConfig;
striped?: boolean;
bordered?: boolean;
compact?: boolean;
emptyMessage?: string;
onRowClick?: (row: T, index: number) => void;
onSelectionChange?: (selectedRows: T[]) => void;
onSort?: (column: string, direction: 'asc' | 'desc') => void;
className?: string;
}
function Table<T extends Record<string, any>>({
data,
columns,
loading = false,
sortable = true,
filterable = false,
selectable = false,
pagination = false,
striped = false,
bordered = false,
compact = false,
emptyMessage = "No data available",
onRowClick,
onSelectionChange,
onSort,
className,
}: TableProps<T>) {
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [searchTerm, setSearchTerm] = useState('');
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const pageSize = typeof pagination === 'object' ? pagination.pageSize || 10 : 10;
const showPagination = Boolean(pagination);
// Filter and sort data
const processedData = useMemo(() => {
let filtered = data;
// Apply search filter
if (searchTerm && filterable) {
filtered = data.filter(row =>
columns.some(col => {
const value = row[col.accessor as keyof T];
return String(value).toLowerCase().includes(searchTerm.toLowerCase());
})
);
}
// Apply sorting
if (sortColumn && sortable) {
filtered = [...filtered].sort((a, b) => {
const aValue = a[sortColumn as keyof T];
const bValue = b[sortColumn as keyof T];
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
}
return filtered;
}, [data, searchTerm, sortColumn, sortDirection, columns, filterable, sortable]);
// Paginate data
const paginatedData = useMemo(() => {
if (!showPagination) return processedData;
const startIndex = (currentPage - 1) * pageSize;
return processedData.slice(startIndex, startIndex + pageSize);
}, [processedData, currentPage, pageSize, showPagination]);
const totalPages = Math.ceil(processedData.length / pageSize);
// Handle sorting
const handleSort = (column: string) => {
if (!sortable) return;
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
onSort?.(column, sortDirection === 'asc' ? 'desc' : 'asc');
};
// Handle row selection
const handleRowSelect = (index: number, checked: boolean) => {
const newSelected = new Set(selectedRows);
if (checked) {
newSelected.add(index);
} else {
newSelected.delete(index);
}
setSelectedRows(newSelected);
onSelectionChange?.(Array.from(newSelected).map(i => paginatedData[i]));
};
// Handle select all
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allIndices = paginatedData.map((_, index) => index);
setSelectedRows(new Set(allIndices));
onSelectionChange?.(paginatedData);
} else {
setSelectedRows(new Set());
onSelectionChange?.([]);
}
};
// Check if all rows are selected
const allSelected = paginatedData.length > 0 && selectedRows.size === paginatedData.length;
const someSelected = selectedRows.size > 0 && selectedRows.size < paginatedData.length;
return (
<div className={cn("w-full", className)}>
{/* Search */}
{filterable && (
<motion.div
className="mb-4 relative"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800 dark:border-gray-600 dark:text-white"
/>
</motion.div>
)}
{/* Table */}
<motion.div
className="overflow-hidden border border-gray-200 rounded-lg dark:border-gray-700"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
<div className="overflow-x-auto">
<table className="w-full">
{/* Table Header */}
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{/* Selection Column */}
{selectable && (
<th className="px-4 py-3 text-left">
<div className="flex items-center">
<input
type="checkbox"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = someSelected;
}}
onChange={(e) => handleSelectAll(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
</th>
)}
{/* Data Columns */}
{columns.map((column, index) => (
<th
key={index}
className={cn(
"px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider",
column.sortable && sortable && "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700",
column.className
)}
style={{ width: column.width }}
onClick={() => column.sortable && handleSort(column.accessor as string)}
>
<div className="flex items-center gap-2">
<span>{column.header}</span>
{/* Sort Indicator */}
{column.sortable && sortable && (
<motion.div
animate={{
rotate: sortColumn === column.accessor
? sortDirection === 'asc' ? 0 : 180
: 0
}}
transition={{ duration: 0.2 }}
>
{sortColumn === column.accessor ? (
sortDirection === 'asc' ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)
) : (
<ChevronsUpDown className="w-4 h-4 opacity-50" />
)}
</motion.div>
)}
</div>
</th>
))}
</tr>
</thead>
{/* Table Body */}
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{/* Loading State */}
{loading && (
<tr>
<td
colSpan={columns.length + (selectable ? 1 : 0)}
className="px-4 py-8 text-center"
>
<div className="flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-blue-500" />
<span className="ml-2 text-gray-500">Loading...</span>
</div>
</td>
</tr>
)}
{/* Empty State */}
{!loading && paginatedData.length === 0 && (
<tr>
<td
colSpan={columns.length + (selectable ? 1 : 0)}
className="px-4 py-8 text-center text-gray-500"
>
{emptyMessage}
</td>
</tr>
)}
{/* Data Rows */}
{!loading && paginatedData.map((row, index) => {
const actualIndex = (currentPage - 1) * pageSize + index;
const isSelected = selectedRows.has(index);
return (
<motion.tr
key={actualIndex}
className={cn(
"hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-200",
striped && index % 2 === 1 && "bg-gray-50 dark:bg-gray-800",
isSelected && "bg-blue-50 dark:bg-blue-950",
onRowClick && "cursor-pointer"
)}
onClick={() => onRowClick?.(row, actualIndex)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
>
{/* Selection Column */}
{selectable && (
<td className="px-4 py-3">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => handleRowSelect(index, e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600"
onClick={(e) => e.stopPropagation()}
/>
</td>
)}
{/* Data Columns */}
{columns.map((column, colIndex) => {
const value = row[column.accessor as keyof T];
const renderedValue = column.render
? column.render(value, row, actualIndex)
: value;
return (
<td
key={colIndex}
className={cn(
"px-4 py-3 text-sm text-gray-900 dark:text-white",
compact ? "py-2" : "py-3",
bordered && "border border-gray-200 dark:border-gray-700",
column.className
)}
>
{renderedValue}
</td>
);
})}
</motion.tr>
);
})}
</tbody>
</table>
</div>
</motion.div>
{/* Pagination */}
{showPagination && totalPages > 1 && (
<motion.div
className="flex items-center justify-between mt-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<div className="text-sm text-gray-700 dark:text-gray-300">
Showing {(currentPage - 1) * pageSize + 1} to {Math.min(currentPage * pageSize, processedData.length)} of {processedData.length} entries
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:hover:bg-gray-800"
>
Previous
</button>
{/* Page Numbers */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = Math.max(1, Math.min(totalPages - 4, currentPage - 2)) + i;
if (pageNum > totalPages) return null;
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={cn(
"px-3 py-1 text-sm border rounded",
pageNum === currentPage
? "bg-blue-500 text-white border-blue-500"
: "border-gray-300 hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-800"
)}
>
{pageNum}
</button>
);
})}
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1 text-sm border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:hover:bg-gray-800"
>
Next
</button>
</div>
</motion.div>
)}
{/* Selection Summary */}
{selectable && selectedRows.size > 0 && (
<motion.div
className="mt-4 p-3 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-center justify-between">
<span className="text-sm text-blue-700 dark:text-blue-300">
{selectedRows.size} row{selectedRows.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={() => {
setSelectedRows(new Set());
onSelectionChange?.([]);
}}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200"
>
Clear selection
</button>
</div>
</motion.div>
)}
</div>
);
}
export default Table;4
Update the import paths to match your project setup
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| data | T[] | [] | Array of data objects to display in the table. |
| columns | Column<T>[] | [] | Column definitions with headers, accessors, and render functions. |
| loading | boolean | false | Show loading state with skeleton rows. |
| sortable | boolean | true | Enable column sorting functionality. |
| filterable | boolean | false | Enable global search/filter functionality. |
| selectable | boolean | false | Enable row selection with checkboxes. |
| pagination | boolean | PaginationConfig | false | Enable pagination with customizable options. |
| striped | boolean | false | Alternate row colors for better readability. |
| bordered | boolean | false | Add borders to table cells. |
| compact | boolean | false | Use compact row spacing. |
| emptyMessage | string | 'No data available' | Message to display when no data is available. |
| onRowClick | (row: T, index: number) => void | undefined | Callback function when a row is clicked. |
| onSelectionChange | (selectedRows: T[]) => void | undefined | Callback function when row selection changes. |
| onSort | (column: string, direction: 'asc' | 'desc') => void | undefined | Callback function when sorting changes. |