Build websites 10x faster with HextaUI Blocks — Learn more
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 Doejohn@example.comAdminactive2024-01-15
Jane Smithjane@example.comManageractive2024-02-20
Mike Johnsonmike@example.comDeveloperinactive2024-03-10
Sarah Wilsonsarah@example.comDesignerpending2024-06-01
Alex Brownalex@example.comDeveloperactive2024-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.

components/ui/Table/table.tsx
"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-001John Doe$199.99completed2024-06-08
ORD-002Jane Smith$299.99processing2024-06-09
ORD-003Mike Johnson$49.99pending2024-06-07
ORD-004Sarah Wilson$129.99cancelled2024-06-06
<DataTable
  data={ordersData}
  columns={columns}
  size="sm"
  compact
  searchPlaceholder="Search orders..."
  itemsPerPage={5}
  showPagination={false}
/>

Minimal Table

Name
Email
Role
Status
John Doejohn@example.comAdminactive
Jane Smithjane@example.comManageractive
Mike Johnsonmike@example.comDeveloperinactive
<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

PropTypeDefault
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

PropTypeDefault
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