UI/UI
Table
A responsive and feature-rich data table component with sorting, filtering, pagination, and customizable rendering.
Name | Email | Role | Status | Join Date |
---|---|---|---|---|
John Doe | john@example.com | Admin | active | 2024-01-15 |
Jane Smith | jane@example.com | Manager | active | 2024-02-20 |
Mike Johnson | mike@example.com | Developer | inactive | 2024-03-10 |
Sarah Wilson | sarah@example.com | Designer | pending | 2024-06-01 |
Alex Brown | alex@example.com | Developer | active | 2024-04-05 |
import { DataTable, DataTableColumn } from "@/components/ui/Table";
import { Badge } from "@/components/ui/Badge";
const usersData = [
{
id: "1",
name: "John Doe",
email: "john@example.com",
role: "Admin",
status: "active",
joinDate: "2024-01-15"
},
// ... more data
];
const columns: DataTableColumn<User>[] = [
{
key: "name",
header: "Name",
sortable: true,
filterable: true
},
{
key: "email",
header: "Email",
sortable: true,
filterable: true
},
{
key: "role",
header: "Role",
sortable: true,
filterable: true
},
{
key: "status",
header: "Status",
sortable: true,
render: (value) => (
<Badge variant={value === "active" ? "default" : "secondary"}>
{value}
</Badge>
)
},
{
key: "joinDate",
header: "Join Date",
sortable: true
}
];
<DataTable
data={usersData}
columns={columns}
searchPlaceholder="Search users..."
itemsPerPage={5}
/>
Installation
Install following dependencies:
npm install lucide-react
pnpm add lucide-react
yarn add lucide-react
bun add lucide-react
Copy and paste the following code into your project.
"use client";
import React, { useState, useMemo } from "react";
import {
ChevronUp,
ChevronDown,
Search,
Filter,
MoreHorizontal,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/Badge";
export type DataTableColumn<T> = {
key: keyof T;
header: string;
sortable?: boolean;
filterable?: boolean;
render?: (value: any, row: T) => React.ReactNode;
width?: string;
align?: "left" | "center" | "right";
};
export type DataTableProps<T> = {
data: T[];
columns: DataTableColumn<T>[];
className?: string;
searchable?: boolean;
searchPlaceholder?: string;
itemsPerPage?: number;
showPagination?: boolean;
striped?: boolean;
hoverable?: boolean;
bordered?: boolean;
compact?: boolean;
loading?: boolean;
emptyMessage?: string;
emptyIcon?: string;
onRowClick?: (row: T, index: number) => void;
variant?: "default" | "minimal" | "bordered";
size?: "sm" | "default" | "lg";
};
export function DataTable<T extends Record<string, any>>({
data,
columns,
className,
searchable = true,
searchPlaceholder = "Search...",
itemsPerPage = 10,
showPagination = true,
striped = false,
hoverable = true,
bordered = true,
compact = false,
loading = false,
emptyMessage = "No data available",
emptyIcon = "📊",
onRowClick,
variant = "default",
size = "default",
}: DataTableProps<T>) {
const [search, setSearch] = useState("");
const [sortConfig, setSortConfig] = useState<{
key: keyof T | null;
direction: "asc" | "desc";
}>({ key: null, direction: "asc" });
const [currentPage, setCurrentPage] = useState(1);
const [columnFilters, setColumnFilters] = useState<Record<string, string>>(
{},
);
// Filter data based on search and column filters
const filteredData = useMemo(() => {
let filtered = [...data];
// Global search
if (search) {
filtered = filtered.filter((row) =>
columns.some((column) => {
const value = row[column.key];
return value?.toString().toLowerCase().includes(search.toLowerCase());
}),
);
}
// Column filters
Object.entries(columnFilters).forEach(([key, value]) => {
if (value) {
filtered = filtered.filter((row) => {
const rowValue = row[key as keyof T];
return rowValue
?.toString()
.toLowerCase()
.includes(value.toLowerCase());
});
}
});
return filtered;
}, [data, search, columnFilters, columns]);
// Sort data
const sortedData = useMemo(() => {
if (!sortConfig.key) return filteredData;
return [...filteredData].sort((a, b) => {
const aValue = a[sortConfig.key!];
const bValue = b[sortConfig.key!];
if (aValue < bValue) {
return sortConfig.direction === "asc" ? -1 : 1;
}
if (aValue > bValue) {
return sortConfig.direction === "asc" ? 1 : -1;
}
return 0;
});
}, [filteredData, sortConfig]);
// Pagination
const paginatedData = useMemo(() => {
if (!showPagination) return sortedData;
const startIndex = (currentPage - 1) * itemsPerPage;
return sortedData.slice(startIndex, startIndex + itemsPerPage);
}, [sortedData, currentPage, itemsPerPage, showPagination]);
const totalPages = Math.ceil(sortedData.length / itemsPerPage);
const handleSort = (key: keyof T) => {
setSortConfig((current) => ({
key,
direction:
current.key === key && current.direction === "asc" ? "desc" : "asc",
}));
};
const handleColumnFilter = (key: string, value: string) => {
setColumnFilters((prev) => ({
...prev,
[key]: value,
}));
setCurrentPage(1);
};
const clearColumnFilter = (key: string) => {
setColumnFilters((prev) => {
const newFilters = { ...prev };
delete newFilters[key];
return newFilters;
});
};
const generatePageNumbers = () => {
const pageNumbers = [];
const maxVisiblePages = 5;
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) {
pageNumbers.push(i);
}
pageNumbers.push("ellipsis");
pageNumbers.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pageNumbers.push(1);
pageNumbers.push("ellipsis");
for (let i = totalPages - 3; i <= totalPages; i++) {
pageNumbers.push(i);
}
} else {
pageNumbers.push(1);
pageNumbers.push("ellipsis");
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pageNumbers.push(i);
}
pageNumbers.push("ellipsis");
pageNumbers.push(totalPages);
}
}
return pageNumbers;
};
if (loading) {
return (
<div
className={cn(
"w-full bg-[hsl(var(--hu-card))] rounded-[var(--radius)] overflow-hidden",
bordered && "border border-[hsl(var(--hu-border))]",
className,
)}
>
<div className="animate-pulse p-6">
{/* Search skeleton */}
{searchable && (
<div className="mb-6 h-10 bg-[hsl(var(--hu-muted))] rounded-[var(--radius)]"></div>
)}
{/* Table skeleton */}
<div className="border border-[hsl(var(--hu-border))] rounded-[var(--radius)] overflow-hidden">
<div className="bg-[hsl(var(--hu-muted))]/30 h-12"></div>
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-14 border-t border-[hsl(var(--hu-border))] bg-[hsl(var(--hu-card))]"
></div>
))}
</div>
{/* Pagination skeleton */}
{showPagination && (
<div className="mt-6 flex justify-between items-center">
<div className="h-4 bg-[hsl(var(--hu-muted))] rounded w-48"></div>
<div className="flex gap-2">
<div className="h-9 w-20 bg-[hsl(var(--hu-muted))] rounded-[var(--radius)]"></div>
<div className="h-9 w-9 bg-[hsl(var(--hu-muted))] rounded-[var(--radius)]"></div>
<div className="h-9 w-9 bg-[hsl(var(--hu-muted))] rounded-[var(--radius)]"></div>
<div className="h-9 w-16 bg-[hsl(var(--hu-muted))] rounded-[var(--radius)]"></div>
</div>
</div>
)}
</div>
</div>
);
}
return (
<div
className={cn(
"w-full bg-[hsl(var(--hu-card))] rounded-[var(--radius)] overflow-hidden",
bordered && "border border-[hsl(var(--hu-border))]",
variant === "minimal" && "bg-transparent border-none",
className,
)}
>
{/* Search and Filters */}
{searchable && (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 p-6 pb-4">
<div className="relative w-full sm:w-auto sm:flex-1 sm:max-w-sm">
<Input
placeholder={searchPlaceholder}
value={search}
onChange={(e) => {
setSearch(e.target.value);
setCurrentPage(1);
}}
leftIcon={<Search />}
clearable
onClear={() => {
setSearch("");
setCurrentPage(1);
}}
className="w-full"
/>
</div>
{Object.keys(columnFilters).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-[hsl(var(--hu-muted-foreground))]">
Active filters:
</span>
{Object.entries(columnFilters).map(([key, value]) => (
<Badge
key={key}
variant="secondary"
className="text-xs cursor-pointer"
onClick={() => clearColumnFilter(key)}
>
{key}: {value} ×
</Badge>
))}
</div>
)}
</div>
)}
{/* Table */}
<div
className={cn(
"overflow-hidden",
variant === "bordered" &&
"border border-[hsl(var(--hu-border))] rounded-[var(--radius)]",
variant === "minimal" && "border-none",
!searchable && variant !== "minimal" && "rounded-[var(--radius)]",
)}
>
<div className="overflow-x-auto">
<table className="w-full min-w-full">
<thead
className={cn(
"bg-[hsl(var(--hu-muted))]/20",
variant === "minimal" &&
"bg-transparent border-b border-[hsl(var(--hu-border))]",
)}
>
<tr>
{columns.map((column) => (
<th
key={String(column.key)}
className={cn(
"text-left font-semibold text-[hsl(var(--hu-foreground))]",
size === "sm" && "px-3 py-2 text-xs",
size === "default" && "px-4 py-3 text-sm",
size === "lg" && "px-6 py-4 text-base",
column.sortable &&
"cursor-pointer hover:bg-[hsl(var(--hu-muted))]/30 transition-colors",
column.align === "center" && "text-center",
column.align === "right" && "text-right",
column.width && `w-[${column.width}]`,
)}
onClick={() => column.sortable && handleSort(column.key)}
style={column.width ? { width: column.width } : undefined}
>
<div
className={cn(
"flex items-center gap-2",
column.align === "center" && "justify-center",
column.align === "right" && "justify-end",
)}
>
<span>{column.header}</span>
{column.sortable && (
<div className="flex flex-col">
<ChevronUp
className={cn(
"h-3 w-3 transition-colors",
sortConfig.key === column.key &&
sortConfig.direction === "asc"
? "text-[hsl(var(--hu-primary))]"
: "text-[hsl(var(--hu-muted-foreground))]/40",
)}
/>
<ChevronDown
className={cn(
"h-3 w-3 -mt-1 transition-colors",
sortConfig.key === column.key &&
sortConfig.direction === "desc"
? "text-[hsl(var(--hu-primary))]"
: "text-[hsl(var(--hu-muted-foreground))]/40",
)}
/>
</div>
)}
{column.filterable && (
<div className="relative">
<Filter className="h-3 w-3 text-[hsl(var(--hu-muted-foreground))]/50" />
</div>
)}
</div>
{/* Column Filter */}
{column.filterable && (
<div className="mt-2">
<Input
placeholder="Filter..."
value={columnFilters[String(column.key)] || ""}
onChange={(e) =>
handleColumnFilter(
String(column.key),
e.target.value,
)
}
onClick={(e) => e.stopPropagation()}
size="sm"
className="text-xs"
/>
</div>
)}
</th>
))}
</tr>
</thead>
<tbody className="bg-[hsl(var(--hu-card))]">
{paginatedData.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className={cn(
"text-center text-[hsl(var(--hu-muted-foreground))] bg-[hsl(var(--hu-card))]",
size === "sm" && "px-3 py-8",
size === "default" && "px-4 py-12",
size === "lg" && "px-6 py-16",
)}
>
<div className="flex flex-col items-center space-y-3">
<div className="text-4xl opacity-50">{emptyIcon}</div>
<div className="font-medium">{emptyMessage}</div>
<div className="text-sm opacity-75">
Try adjusting your search or filter criteria
</div>
</div>
</td>
</tr>
) : (
paginatedData.map((row, index) => (
<tr
key={index}
className={cn(
"border-t border-[hsl(var(--hu-border))] bg-[hsl(var(--hu-card))] transition-colors",
striped &&
index % 2 === 0 &&
"bg-[hsl(var(--hu-muted))]/10",
hoverable && "hover:bg-[hsl(var(--hu-muted))]/20",
onRowClick && "cursor-pointer",
"group",
)}
onClick={() => onRowClick?.(row, index)}
>
{columns.map((column) => (
<td
key={String(column.key)}
className={cn(
"text-[hsl(var(--hu-foreground))]",
size === "sm" && "px-3 py-2 text-xs",
size === "default" && "px-4 py-3 text-sm",
size === "lg" && "px-6 py-4 text-base",
column.align === "center" && "text-center",
column.align === "right" && "text-right",
)}
>
{column.render
? column.render(row[column.key], row)
: String(row[column.key] ?? "")}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{showPagination && totalPages > 1 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 p-6 pt-4 bg-[hsl(var(--hu-card))] border-t border-[hsl(var(--hu-border))]">
<div className="text-sm text-[hsl(var(--hu-muted-foreground))] order-2 sm:order-1">
Showing {(currentPage - 1) * itemsPerPage + 1} to{" "}
{Math.min(currentPage * itemsPerPage, sortedData.length)} of{" "}
{sortedData.length} results
</div>
<div className="flex items-center gap-2 order-1 sm:order-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
>
Previous
</Button>
<div className="hidden sm:flex items-center gap-1">
{generatePageNumbers().map((pageNumber, index) => {
if (pageNumber === "ellipsis") {
return (
<Button
key={`ellipsis-${index}`}
variant="ghost"
size="sm"
disabled
className="cursor-default"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
);
}
return (
<Button
key={pageNumber}
variant={currentPage === pageNumber ? "default" : "ghost"}
size="sm"
onClick={() => setCurrentPage(pageNumber as number)}
>
{pageNumber}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}
npx hextaui@latest add table
pnpm dlx hextaui@latest add table
yarn dlx hextaui@latest add table
bun x hextaui@latest add table
Usage
import { DataTable, DataTableColumn } from "@/components/ui/Table";
const data = [
{ id: 1, name: "John Doe", email: "john@example.com", role: "Admin" },
{ id: 2, name: "Jane Smith", email: "jane@example.com", role: "User" },
];
const columns: DataTableColumn<User>[] = [
{ key: "name", header: "Name", sortable: true },
{ key: "email", header: "Email", sortable: true },
{ key: "role", header: "Role" },
];
<DataTable data={data} columns={columns} />;
Examples
Advanced Table with Custom Renders
User | Contact | Role | Projects | Rating | Status | Actions |
---|---|---|---|---|---|---|
J John Doe Engineering | john@example.com +1 (555) 123-4567 | Admin | 5 | 4.8 | active | |
J Jane Smith Marketing | jane@example.com +1 (555) 987-6543 | Manager | 8 | 4.9 | active | |
M Mike Johnson Engineering | mike@example.com +1 (555) 456-7890 | Developer | 3 | 4.5 | inactive | |
S Sarah Wilson Design | sarah@example.com +1 (555) 321-9876 | Designer | 0 | 0 | pending | |
A Alex Brown Engineering | alex@example.com +1 (555) 654-3210 | Developer | 7 | 4.7 | active |
import { DataTable, DataTableColumn } from "@/components/ui/Table";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { Star, Edit, Trash2, Eye } from "lucide-react";
const columns: DataTableColumn<User>[] = [
{
key: "name",
header: "User",
sortable: true,
filterable: true,
render: (value, row) => (
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<div className="flex items-center justify-center w-full h-full bg-[hsl(var(--hu-primary))] text-[hsl(var(--hu-primary-foreground))] text-xs font-medium">
{value.charAt(0)}
</div>
</Avatar>
<div>
<div className="font-medium">{value}</div>
<div className="text-xs text-[hsl(var(--hu-muted-foreground))]">
{row.department}
</div>
</div>
</div>
)
},
{
key: "rating",
header: "Rating",
sortable: true,
align: "center",
render: (value) => (
<div className="flex items-center gap-1">
<Star className="h-4 w-4 text-yellow-500 fill-current" />
<span className="font-medium">{value}</span>
</div>
)
},
{
key: "id",
header: "Actions",
align: "right",
render: (value, row) => (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
)
}
];
<DataTable
data={usersData}
columns={columns}
searchPlaceholder="Search users..."
hoverable
onRowClick={(row) => console.log("Clicked user:", row.name)}
/>
Products Table
Product | Price | Stock | Status | Rating | Sales |
---|---|---|---|---|---|
Wireless Headphones Electronics | $199.99 | 45 | in stock | 4.5 | 234 |
Smart Watch Electronics | $299.99 | 5 | low stock | 4.8 | 156 |
Laptop Stand Accessories | $49.99 | 0 | out of-stock | 4.2 | 89 |
Mechanical Keyboard Electronics | $129.99 | 23 | in stock | 4.7 | 178 |
Desk Lamp Home & Office | $79.99 | 12 | in stock | 4.3 | 67 |
const columns: DataTableColumn<Product>[] = [
{
key: "name",
header: "Product",
sortable: true,
filterable: true,
render: (value, row) => (
<div>
<div className="font-medium">{value}</div>
<div className="text-xs text-[hsl(var(--hu-muted-foreground))]">
{row.category}
</div>
</div>
)
},
{
key: "price",
header: "Price",
sortable: true,
align: "right",
render: (value) => (
<div className="font-medium">${value.toFixed(2)}</div>
)
},
{
key: "status",
header: "Status",
sortable: true,
filterable: true,
render: (value) => (
<Badge variant={value === "in-stock" ? "default" : "outline"}>
{value.replace("-", " ")}
</Badge>
)
}
];
<DataTable
data={productsData}
columns={columns}
searchPlaceholder="Search products..."
striped
/>
Compact Table
Order ID | Customer | Amount | Status | Date |
---|---|---|---|---|
ORD-001 | John Doe | $199.99 | completed | 2024-06-08 |
ORD-002 | Jane Smith | $299.99 | processing | 2024-06-09 |
ORD-003 | Mike Johnson | $49.99 | pending | 2024-06-07 |
ORD-004 | Sarah Wilson | $129.99 | cancelled | 2024-06-06 |
<DataTable
data={ordersData}
columns={columns}
size="sm"
compact
searchPlaceholder="Search orders..."
itemsPerPage={5}
showPagination={false}
/>
Minimal Table
Name | Email | Role | Status |
---|---|---|---|
John Doe | john@example.com | Admin | active |
Jane Smith | jane@example.com | Manager | active |
Mike Johnson | mike@example.com | Developer | inactive |
<DataTable
data={usersData.slice(0, 3)}
columns={columns}
variant="minimal"
searchable={false}
showPagination={false}
hoverable
/>
Loading State
<DataTable
data={[]}
columns={[]}
loading={true}
searchPlaceholder="Search..."
/>
Empty State
Name | Email | Role | Status |
---|---|---|---|
👥 No users found Try adjusting your search or filter criteria |
<DataTable
data={[]}
columns={columns}
emptyMessage="No users found"
emptyIcon="👥"
searchPlaceholder="Search users..."
/>
Advanced Usage
// Custom cell renderer with complex logic
const statusRenderer = (value: string, row: User) => {
const getStatusConfig = (status: string) => {
switch (status) {
case "active":
return { variant: "default", color: "green", icon: CheckCircle };
case "inactive":
return { variant: "secondary", color: "gray", icon: XCircle };
default:
return { variant: "outline", color: "yellow", icon: Clock };
}
};
const config = getStatusConfig(value);
return (
<Badge variant={config.variant} className="gap-1">
<config.icon className={`h-3 w-3 text-${config.color}-600`} />
{value}
</Badge>
);
};
// Row click handler with navigation
const handleRowClick = (user: User) => {
router.push(`/users/${user.id}`);
};
// Custom empty state
<DataTable
data={filteredUsers}
columns={columns}
emptyMessage="No users match your criteria"
emptyIcon="🔍"
onRowClick={handleRowClick}
/>;
Props
Table
Prop | Type | Default |
---|---|---|
size? | 'sm' | 'default' | 'lg' | 'default' |
variant? | 'default' | 'minimal' | 'bordered' | 'default' |
onRowClick? | (row: T, index: number) => void | - |
emptyIcon? | string | '📊' |
emptyMessage? | string | 'No data available' |
loading? | boolean | false |
bordered? | boolean | true |
hoverable? | boolean | true |
striped? | boolean | false |
showPagination? | boolean | true |
itemsPerPage? | number | 10 |
searchPlaceholder? | string | 'Search...' |
searchable? | boolean | true |
columns | DataTableColumn<T>[] | - |
data | T[] | - |
Column Configuration
Prop | Type | Default |
---|---|---|
align? | 'left' | 'center' | 'right' | 'left' |
width? | string | - |
render? | (value: any, row: T) => React.ReactNode | - |
filterable? | boolean | - |
sortable? | boolean | - |
header | string | - |
key | keyof T | - |
Edit on GitHub
Last updated on