Components
Animated Table
A feature-rich, animated data table component with sorting, selection, search, pagination, and expandable rows.
Last updated on
Basic Table
import { User } from "lucide-react";
import {
AnimatedTable,
type ColumnDef,
} from "@/components/ui/animated-table";
interface UserData {
id: string;
name: string;
email: string;
role: string;
status: "active" | "inactive" | "pending";
}
const data: UserData[] = [
{
id: "1",
name: "Alice Johnson",
email: "alice@example.com",
role: "Admin",
status: "active",
},
{
id: "2",
name: "Bob Smith",
email: "bob@example.com",
role: "Developer",
status: "active",
},
{
id: "3",
name: "Carol White",
email: "carol@example.com",
role: "Designer",
status: "pending",
},
{
id: "4",
name: "David Brown",
email: "david@example.com",
role: "Developer",
status: "inactive",
},
{
id: "5",
name: "Emma Davis",
email: "emma@example.com",
role: "Manager",
status: "active",
},
];
const StatusBadge = ({ status }: { status: UserData["status"] }) => {
const styles = {
active: "bg-green-500/20 text-green-400 border-green-500/30",
inactive: "bg-red-500/20 text-red-400 border-red-500/30",
pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
};
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium text-xs ${styles[status]}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${status === "active" ? "bg-green-400" : status === "inactive" ? "bg-red-400" : "bg-yellow-400"}`}
/>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
const columns: ColumnDef<UserData>[] = [
{
id: "name",
header: "Name",
sortable: true,
cell: (row) => (
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/20 text-primary">
<User className="h-4 w-4" />
</div>
<span className="font-medium text-foreground">{row.name}</span>
</div>
),
},
{
id: "email",
header: "Email",
accessorKey: "email",
sortable: true,
},
{
id: "role",
header: "Role",
accessorKey: "role",
sortable: true,
},
{
id: "status",
header: "Status",
sortable: true,
align: "center",
cell: (row) => <StatusBadge status={row.status} />,
},
];
export function AnimatedTableBasicDemo() {
return (
<div className="flex flex-col gap-3">
<AnimatedTable data={data} columns={columns} />
</div>
);
}Sorting
import { User } from "lucide-react";
import { useMemo, useState } from "react";
import {
AnimatedTable,
type ColumnDef,
type SortDirection,
} from "@/components/ui/animated-table";
interface UserData {
id: string;
name: string;
email: string;
role: string;
status: "active" | "inactive" | "pending";
}
const data: UserData[] = [
{
id: "1",
name: "Alice Johnson",
email: "alice@example.com",
role: "Admin",
status: "active",
},
{
id: "2",
name: "Bob Smith",
email: "bob@example.com",
role: "Developer",
status: "active",
},
{
id: "3",
name: "Carol White",
email: "carol@example.com",
role: "Designer",
status: "pending",
},
{
id: "4",
name: "David Brown",
email: "david@example.com",
role: "Developer",
status: "inactive",
},
{
id: "5",
name: "Emma Davis",
email: "emma@example.com",
role: "Manager",
status: "active",
},
];
const StatusBadge = ({ status }: { status: UserData["status"] }) => {
const styles = {
active: "bg-green-500/20 text-green-400 border-green-500/30",
inactive: "bg-red-500/20 text-red-400 border-red-500/30",
pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
};
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium text-xs ${styles[status]}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${status === "active" ? "bg-green-400" : status === "inactive" ? "bg-red-400" : "bg-yellow-400"}`}
/>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
const columns: ColumnDef<UserData>[] = [
{
id: "name",
header: "Name",
sortable: true,
cell: (row) => (
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/20 text-primary">
<User className="h-4 w-4" />
</div>
<span className="font-medium text-foreground">{row.name}</span>
</div>
),
},
{
id: "email",
header: "Email",
accessorKey: "email",
sortable: true,
},
{
id: "role",
header: "Role",
accessorKey: "role",
sortable: true,
},
{
id: "status",
header: "Status",
sortable: true,
align: "center",
cell: (row) => <StatusBadge status={row.status} />,
},
];
export function AnimatedTableSortDemo() {
const [sortColumn, setSortColumn] = useState<string | undefined>();
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
const handleSort = (columnId: string, direction: SortDirection) => {
setSortColumn(columnId);
setSortDirection(direction);
};
const sortedData = useMemo(() => {
if (!sortColumn || !sortDirection) return data;
return [...data].sort((a, b) => {
const aVal = a[sortColumn as keyof UserData];
const bVal = b[sortColumn as keyof UserData];
if (typeof aVal === "string" && typeof bVal === "string") {
return sortDirection === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
return 0;
});
}, [sortColumn, sortDirection]);
return (
<div className="flex flex-col gap-3">
<AnimatedTable
data={sortedData}
columns={columns}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
/>
</div>
);
}Search
import { User } from "lucide-react";
import { useMemo, useState } from "react";
import {
AnimatedTable,
type ColumnDef,
} from "@/components/ui/animated-table";
interface UserData {
id: string;
name: string;
email: string;
role: string;
status: "active" | "inactive" | "pending";
}
const data: UserData[] = [
{
id: "1",
name: "Alice Johnson",
email: "alice@example.com",
role: "Admin",
status: "active",
},
{
id: "2",
name: "Bob Smith",
email: "bob@example.com",
role: "Developer",
status: "active",
},
{
id: "3",
name: "Carol White",
email: "carol@example.com",
role: "Designer",
status: "pending",
},
{
id: "4",
name: "David Brown",
email: "david@example.com",
role: "Developer",
status: "inactive",
},
{
id: "5",
name: "Emma Davis",
email: "emma@example.com",
role: "Manager",
status: "active",
},
{
id: "6",
name: "Frank Miller",
email: "frank@example.com",
role: "Developer",
status: "active",
},
{
id: "7",
name: "Grace Lee",
email: "grace@example.com",
role: "Designer",
status: "active",
},
];
const StatusBadge = ({ status }: { status: UserData["status"] }) => {
const styles = {
active: "bg-green-500/20 text-green-400 border-green-500/30",
inactive: "bg-red-500/20 text-red-400 border-red-500/30",
pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
};
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium text-xs ${styles[status]}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${status === "active" ? "bg-green-400" : status === "inactive" ? "bg-red-400" : "bg-yellow-400"}`}
/>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
const columns: ColumnDef<UserData>[] = [
{
id: "name",
header: "Name",
sortable: true,
cell: (row) => (
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/20 text-primary">
<User className="h-4 w-4" />
</div>
<span className="font-medium text-foreground">{row.name}</span>
</div>
),
},
{
id: "email",
header: "Email",
accessorKey: "email",
sortable: true,
},
{
id: "role",
header: "Role",
accessorKey: "role",
sortable: true,
},
{
id: "status",
header: "Status",
sortable: true,
align: "center",
cell: (row) => <StatusBadge status={row.status} />,
},
];
export function AnimatedTableSearchDemo() {
const [searchValue, setSearchValue] = useState("");
const filteredData = useMemo(() => {
if (!searchValue) return data;
const search = searchValue.toLowerCase();
return data.filter(
(row) =>
row.name.toLowerCase().includes(search) ||
row.email.toLowerCase().includes(search) ||
row.role.toLowerCase().includes(search),
);
}, [searchValue]);
return (
<div className="flex flex-col gap-3">
<AnimatedTable
data={filteredData}
columns={columns}
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
searchPlaceholder="Search users..."
/>
</div>
);
}Selection
import { User } from "lucide-react";
import { useState } from "react";
import {
AnimatedTable,
type ColumnDef,
} from "@/components/ui/animated-table";
interface UserData {
id: string;
name: string;
email: string;
role: string;
status: "active" | "inactive" | "pending";
}
const data: UserData[] = [
{
id: "1",
name: "Alice Johnson",
email: "alice@example.com",
role: "Admin",
status: "active",
},
{
id: "2",
name: "Bob Smith",
email: "bob@example.com",
role: "Developer",
status: "active",
},
{
id: "3",
name: "Carol White",
email: "carol@example.com",
role: "Designer",
status: "pending",
},
{
id: "4",
name: "David Brown",
email: "david@example.com",
role: "Developer",
status: "inactive",
},
{
id: "5",
name: "Emma Davis",
email: "emma@example.com",
role: "Manager",
status: "active",
},
];
const StatusBadge = ({ status }: { status: UserData["status"] }) => {
const styles = {
active: "bg-green-500/20 text-green-400 border-green-500/30",
inactive: "bg-red-500/20 text-red-400 border-red-500/30",
pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
};
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium text-xs ${styles[status]}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${status === "active" ? "bg-green-400" : status === "inactive" ? "bg-red-400" : "bg-yellow-400"}`}
/>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
const columns: ColumnDef<UserData>[] = [
{
id: "name",
header: "Name",
sortable: true,
cell: (row) => (
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/20 text-primary">
<User className="h-4 w-4" />
</div>
<span className="font-medium text-foreground">{row.name}</span>
</div>
),
},
{
id: "email",
header: "Email",
accessorKey: "email",
sortable: true,
},
{
id: "role",
header: "Role",
accessorKey: "role",
sortable: true,
},
{
id: "status",
header: "Status",
sortable: true,
align: "center",
cell: (row) => <StatusBadge status={row.status} />,
},
];
export function AnimatedTableSelectDemo() {
const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
return (
<div className="flex flex-col gap-3">
<AnimatedTable
data={data}
columns={columns}
selectable
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
/>
{selectedIds.length > 0 && (
<p className="text-muted-foreground text-sm">
{selectedIds.length} row{selectedIds.length > 1 ? "s" : ""} selected
</p>
)}
</div>
);
}Expandable Rows
import { Mail, MapPin, Phone, User } from "lucide-react";
import {
AnimatedTable,
type ColumnDef,
} from "@/components/ui/animated-table";
interface UserData {
id: string;
name: string;
email: string;
role: string;
status: "active" | "inactive" | "pending";
location: string;
phone: string;
}
const data: UserData[] = [
{
id: "1",
name: "Alice Johnson",
email: "alice@example.com",
role: "Admin",
status: "active",
location: "New York, NY",
phone: "+1 555-0101",
},
{
id: "2",
name: "Bob Smith",
email: "bob@example.com",
role: "Developer",
status: "active",
location: "San Francisco, CA",
phone: "+1 555-0102",
},
{
id: "3",
name: "Carol White",
email: "carol@example.com",
role: "Designer",
status: "pending",
location: "Los Angeles, CA",
phone: "+1 555-0103",
},
];
const StatusBadge = ({ status }: { status: UserData["status"] }) => {
const styles = {
active: "bg-green-500/20 text-green-400 border-green-500/30",
inactive: "bg-red-500/20 text-red-400 border-red-500/30",
pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
};
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium text-xs ${styles[status]}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${status === "active" ? "bg-green-400" : status === "inactive" ? "bg-red-400" : "bg-yellow-400"}`}
/>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
const columns: ColumnDef<UserData>[] = [
{
id: "name",
header: "Name",
sortable: true,
cell: (row) => (
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/20 text-primary">
<User className="h-4 w-4" />
</div>
<span className="font-medium text-foreground">{row.name}</span>
</div>
),
},
{
id: "email",
header: "Email",
accessorKey: "email",
sortable: true,
},
{
id: "role",
header: "Role",
accessorKey: "role",
sortable: true,
},
{
id: "status",
header: "Status",
sortable: true,
align: "center",
cell: (row) => <StatusBadge status={row.status} />,
},
];
const ExpandedRowContent = ({ row }: { row: UserData }) => (
<div className="grid gap-4 pl-10 sm:grid-cols-2">
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Email:</span>
<span className="text-foreground">{row.email}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Phone:</span>
<span className="text-foreground">{row.phone}</span>
</div>
<div className="flex items-center gap-2 text-sm sm:col-span-2">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Location:</span>
<span className="text-foreground">{row.location}</span>
</div>
</div>
);
export function AnimatedTableExpandDemo() {
return (
<div className="flex flex-col gap-3">
<AnimatedTable
data={data}
columns={columns}
expandable
renderExpandedRow={(row) => <ExpandedRowContent row={row} />}
/>
</div>
);
}Column Visibility
import { User } from "lucide-react";
import { useState } from "react";
import {
AnimatedTable,
type ColumnDef,
} from "@/components/ui/animated-table";
interface UserData {
id: string;
name: string;
email: string;
role: string;
status: "active" | "inactive" | "pending";
department: string;
}
const data: UserData[] = [
{
id: "1",
name: "Alice Johnson",
email: "alice@example.com",
role: "Admin",
status: "active",
department: "Engineering",
},
{
id: "2",
name: "Bob Smith",
email: "bob@example.com",
role: "Developer",
status: "active",
department: "Engineering",
},
{
id: "3",
name: "Carol White",
email: "carol@example.com",
role: "Designer",
status: "pending",
department: "Design",
},
{
id: "4",
name: "David Brown",
email: "david@example.com",
role: "Developer",
status: "inactive",
department: "Engineering",
},
{
id: "5",
name: "Emma Davis",
email: "emma@example.com",
role: "Manager",
status: "active",
department: "Management",
},
];
const StatusBadge = ({ status }: { status: UserData["status"] }) => {
const styles = {
active: "bg-green-500/20 text-green-400 border-green-500/30",
inactive: "bg-red-500/20 text-red-400 border-red-500/30",
pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
};
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium text-xs ${styles[status]}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${status === "active" ? "bg-green-400" : status === "inactive" ? "bg-red-400" : "bg-yellow-400"}`}
/>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
const columns: ColumnDef<UserData>[] = [
{
id: "name",
header: "Name",
sortable: true,
hideable: false,
cell: (row) => (
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/20 text-primary">
<User className="h-4 w-4" />
</div>
<span className="font-medium text-foreground">{row.name}</span>
</div>
),
},
{
id: "email",
header: "Email",
accessorKey: "email",
sortable: true,
},
{
id: "role",
header: "Role",
accessorKey: "role",
sortable: true,
},
{
id: "department",
header: "Department",
accessorKey: "department",
sortable: true,
},
{
id: "status",
header: "Status",
sortable: true,
align: "center",
cell: (row) => <StatusBadge status={row.status} />,
},
];
export function AnimatedTableColumnDemo() {
const [visibleColumns, setVisibleColumns] = useState<string[]>(
columns.map((c) => c.id),
);
return (
<div className="flex flex-col gap-3">
<AnimatedTable
data={data}
columns={columns}
columnVisibility
visibleColumns={visibleColumns}
onVisibleColumnsChange={setVisibleColumns}
/>
</div>
);
}Pagination
import { User } from "lucide-react";
import { useMemo, useState } from "react";
import {
AnimatedTable,
type ColumnDef,
} from "@/components/ui/animated-table";
interface UserData {
id: string;
name: string;
email: string;
role: string;
status: "active" | "inactive" | "pending";
}
const generateData = (): UserData[] => [
{
id: "1",
name: "Alice Johnson",
email: "alice@example.com",
role: "Admin",
status: "active",
},
{
id: "2",
name: "Bob Smith",
email: "bob@example.com",
role: "Developer",
status: "active",
},
{
id: "3",
name: "Carol White",
email: "carol@example.com",
role: "Designer",
status: "pending",
},
{
id: "4",
name: "David Brown",
email: "david@example.com",
role: "Developer",
status: "inactive",
},
{
id: "5",
name: "Emma Davis",
email: "emma@example.com",
role: "Manager",
status: "active",
},
{
id: "6",
name: "Frank Miller",
email: "frank@example.com",
role: "Developer",
status: "active",
},
{
id: "7",
name: "Grace Lee",
email: "grace@example.com",
role: "Designer",
status: "active",
},
{
id: "8",
name: "Henry Wilson",
email: "henry@example.com",
role: "Developer",
status: "pending",
},
{
id: "9",
name: "Ivy Chen",
email: "ivy@example.com",
role: "Manager",
status: "active",
},
{
id: "10",
name: "Jack Taylor",
email: "jack@example.com",
role: "Developer",
status: "active",
},
{
id: "11",
name: "Kate Anderson",
email: "kate@example.com",
role: "Designer",
status: "inactive",
},
{
id: "12",
name: "Liam Harris",
email: "liam@example.com",
role: "Developer",
status: "active",
},
];
const allData = generateData();
const StatusBadge = ({ status }: { status: UserData["status"] }) => {
const styles = {
active: "bg-green-500/20 text-green-400 border-green-500/30",
inactive: "bg-red-500/20 text-red-400 border-red-500/30",
pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
};
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium text-xs ${styles[status]}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${status === "active" ? "bg-green-400" : status === "inactive" ? "bg-red-400" : "bg-yellow-400"}`}
/>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
const columns: ColumnDef<UserData>[] = [
{
id: "name",
header: "Name",
sortable: true,
cell: (row) => (
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/20 text-primary">
<User className="h-4 w-4" />
</div>
<span className="font-medium text-foreground">{row.name}</span>
</div>
),
},
{
id: "email",
header: "Email",
accessorKey: "email",
sortable: true,
},
{
id: "role",
header: "Role",
accessorKey: "role",
sortable: true,
},
{
id: "status",
header: "Status",
sortable: true,
align: "center",
cell: (row) => <StatusBadge status={row.status} />,
},
];
export function AnimatedTablePaginationDemo() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(5);
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
setPage(1);
};
const paginatedData = useMemo(() => {
const start = (page - 1) * pageSize;
return allData.slice(start, start + pageSize);
}, [page, pageSize]);
return (
<div className="flex flex-col gap-3">
<AnimatedTable
data={paginatedData}
columns={columns}
pagination={{
page,
pageSize,
totalItems: allData.length,
pageSizeOptions: [5, 10, 20],
onPageChange: setPage,
onPageSizeChange: handlePageSizeChange,
}}
/>
</div>
);
}Full Featured
import { Mail, MapPin, Phone, User } from "lucide-react";
import { useMemo, useState } from "react";
import {
AnimatedTable,
type ColumnDef,
type SortDirection,
} from "@/components/ui/animated-table";
interface UserData {
id: string;
name: string;
email: string;
role: string;
status: "active" | "inactive" | "pending";
department: string;
joinDate: string;
location: string;
phone: string;
}
const generateData = (): UserData[] => [
{
id: "1",
name: "Alice Johnson",
email: "alice@example.com",
role: "Admin",
status: "active",
department: "Engineering",
joinDate: "2024-01-15",
location: "New York, NY",
phone: "+1 555-0101",
},
{
id: "2",
name: "Bob Smith",
email: "bob@example.com",
role: "Developer",
status: "active",
department: "Engineering",
joinDate: "2024-02-20",
location: "San Francisco, CA",
phone: "+1 555-0102",
},
{
id: "3",
name: "Carol White",
email: "carol@example.com",
role: "Designer",
status: "pending",
department: "Design",
joinDate: "2024-03-10",
location: "Los Angeles, CA",
phone: "+1 555-0103",
},
{
id: "4",
name: "David Brown",
email: "david@example.com",
role: "Developer",
status: "inactive",
department: "Engineering",
joinDate: "2023-11-05",
location: "Chicago, IL",
phone: "+1 555-0104",
},
{
id: "5",
name: "Emma Davis",
email: "emma@example.com",
role: "Manager",
status: "active",
department: "Management",
joinDate: "2023-08-22",
location: "Boston, MA",
phone: "+1 555-0105",
},
{
id: "6",
name: "Frank Miller",
email: "frank@example.com",
role: "Developer",
status: "active",
department: "Engineering",
joinDate: "2024-04-01",
location: "Seattle, WA",
phone: "+1 555-0106",
},
{
id: "7",
name: "Grace Lee",
email: "grace@example.com",
role: "Designer",
status: "active",
department: "Design",
joinDate: "2024-01-30",
location: "Austin, TX",
phone: "+1 555-0107",
},
{
id: "8",
name: "Henry Wilson",
email: "henry@example.com",
role: "Developer",
status: "pending",
department: "Engineering",
joinDate: "2024-05-15",
location: "Denver, CO",
phone: "+1 555-0108",
},
{
id: "9",
name: "Ivy Chen",
email: "ivy@example.com",
role: "Manager",
status: "active",
department: "Management",
joinDate: "2023-06-12",
location: "Portland, OR",
phone: "+1 555-0109",
},
{
id: "10",
name: "Jack Taylor",
email: "jack@example.com",
role: "Developer",
status: "active",
department: "Engineering",
joinDate: "2024-02-28",
location: "Miami, FL",
phone: "+1 555-0110",
},
];
const allData = generateData();
const StatusBadge = ({ status }: { status: UserData["status"] }) => {
const styles = {
active: "bg-green-500/20 text-green-400 border-green-500/30",
inactive: "bg-red-500/20 text-red-400 border-red-500/30",
pending: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
};
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 font-medium text-xs ${styles[status]}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${status === "active" ? "bg-green-400" : status === "inactive" ? "bg-red-400" : "bg-yellow-400"}`}
/>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
const columns: ColumnDef<UserData>[] = [
{
id: "name",
header: "Name",
sortable: true,
hideable: false,
cell: (row) => (
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/20 text-primary">
<User className="h-4 w-4" />
</div>
<span className="font-medium text-foreground">{row.name}</span>
</div>
),
},
{
id: "email",
header: "Email",
accessorKey: "email",
sortable: true,
},
{
id: "role",
header: "Role",
accessorKey: "role",
sortable: true,
},
{
id: "department",
header: "Department",
accessorKey: "department",
sortable: true,
},
{
id: "status",
header: "Status",
sortable: true,
align: "center",
cell: (row) => <StatusBadge status={row.status} />,
},
{
id: "joinDate",
header: "Join Date",
sortable: true,
align: "right",
cell: (row) => (
<span className="text-muted-foreground">
{new Date(row.joinDate).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
),
},
];
const ExpandedRowContent = ({ row }: { row: UserData }) => (
<div className="grid gap-4 pl-10 sm:grid-cols-3">
<div className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Email:</span>
<span className="text-foreground">{row.email}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Phone className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Phone:</span>
<span className="text-foreground">{row.phone}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Location:</span>
<span className="text-foreground">{row.location}</span>
</div>
</div>
);
export function AnimatedTableFullDemo() {
const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
const [sortColumn, setSortColumn] = useState<string | undefined>();
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
const [searchValue, setSearchValue] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(5);
const [visibleColumns, setVisibleColumns] = useState<string[]>(
columns.map((c) => c.id),
);
const handleSort = (columnId: string, direction: SortDirection) => {
setSortColumn(columnId);
setSortDirection(direction);
};
// Filter data based on search
const filteredData = useMemo(() => {
if (!searchValue) return allData;
const search = searchValue.toLowerCase();
return allData.filter(
(row) =>
row.name.toLowerCase().includes(search) ||
row.email.toLowerCase().includes(search) ||
row.role.toLowerCase().includes(search) ||
row.department.toLowerCase().includes(search),
);
}, [searchValue]);
// Sort filtered data
const sortedData = useMemo(() => {
if (!sortColumn || !sortDirection) return filteredData;
return [...filteredData].sort((a, b) => {
const aVal = a[sortColumn as keyof UserData];
const bVal = b[sortColumn as keyof UserData];
if (typeof aVal === "string" && typeof bVal === "string") {
return sortDirection === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
return 0;
});
}, [filteredData, sortColumn, sortDirection]);
// Paginate sorted data
const paginatedData = useMemo(() => {
const start = (page - 1) * pageSize;
return sortedData.slice(start, start + pageSize);
}, [sortedData, page, pageSize]);
// Reset to page 1 when search changes
const handleSearchChange = (value: string) => {
setSearchValue(value);
setPage(1);
};
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
setPage(1);
};
const handleRowClick = (row: UserData) => {
console.log("Clicked:", row.name);
};
return (
<div className="flex flex-col gap-3">
<AnimatedTable
data={paginatedData}
columns={columns}
selectable
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
onRowClick={handleRowClick}
sortColumn={sortColumn}
sortDirection={sortDirection}
onSort={handleSort}
striped
searchable
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="Search users..."
expandable
renderExpandedRow={(row) => <ExpandedRowContent row={row} />}
columnVisibility
visibleColumns={visibleColumns}
onVisibleColumnsChange={setVisibleColumns}
pagination={{
page,
pageSize,
totalItems: sortedData.length,
pageSizeOptions: [5, 10, 20],
onPageChange: setPage,
onPageSizeChange: handlePageSizeChange,
}}
/>
{selectedIds.length > 0 && (
<p className="text-muted-foreground text-sm">
{selectedIds.length} row{selectedIds.length > 1 ? "s" : ""} selected
</p>
)}
</div>
);
}Installation
CLI
npx shadcn@latest add "https://jolyui.dev/r/animated-table"Manual
Install the following dependencies:
npm install lucide-react motionCopy and paste the following code into your project.
import {
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronsUpDown,
ChevronUp,
Columns3,
Search,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
// Types
export type SortDirection = "asc" | "desc" | null;
export interface ColumnDef<T> {
id: string;
header: React.ReactNode;
accessorKey?: keyof T;
cell?: (row: T, index: number) => React.ReactNode;
sortable?: boolean;
filterable?: boolean;
align?: "left" | "center" | "right";
width?: string;
hideable?: boolean;
}
export interface PaginationConfig {
page: number;
pageSize: number;
totalItems: number;
pageSizeOptions?: number[];
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
}
export interface AnimatedTableProps<T extends { id: string | number }> {
data: T[];
columns: ColumnDef<T>[];
selectable?: boolean;
selectedIds?: (string | number)[];
onSelectionChange?: (ids: (string | number)[]) => void;
onRowClick?: (row: T) => void;
sortColumn?: string;
sortDirection?: SortDirection;
onSort?: (columnId: string, direction: SortDirection) => void;
striped?: boolean;
stickyHeader?: boolean;
className?: string;
emptyMessage?: React.ReactNode;
loading?: boolean;
loadingRows?: number;
// New features
searchable?: boolean;
searchValue?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
pagination?: PaginationConfig;
expandable?: boolean;
renderExpandedRow?: (row: T) => React.ReactNode;
columnVisibility?: boolean;
visibleColumns?: string[];
onVisibleColumnsChange?: (columns: string[]) => void;
}
// Search Input Component
const TableSearch = ({
value,
onChange,
placeholder = "Search...",
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) => (
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 h-4 w-4 text-muted-foreground" />
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="h-9 w-full rounded-md border border-table-border bg-background pr-8 pl-9 text-foreground text-sm placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
<AnimatePresence>
{value && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={() => onChange("")}
className="-translate-y-1/2 absolute top-1/2 right-2 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</motion.button>
)}
</AnimatePresence>
</div>
);
// Column Visibility Dropdown
const ColumnVisibilityDropdown = <T,>({
columns,
visibleColumns,
onChange,
}: {
columns: ColumnDef<T>[];
visibleColumns: string[];
onChange: (columns: string[]) => void;
}) => {
const [open, setOpen] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const hideableColumns = columns.filter((col) => col.hideable !== false);
const toggleColumn = (columnId: string) => {
if (visibleColumns.includes(columnId)) {
if (visibleColumns.length > 1) {
onChange(visibleColumns.filter((id) => id !== columnId));
}
} else {
onChange([...visibleColumns, columnId]);
}
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setOpen(!open)}
className={cn(
"flex h-9 items-center gap-2 rounded-md border border-table-border bg-background px-3 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground",
open && "border-primary text-foreground",
)}
>
<Columns3 className="h-4 w-4" />
<span>Columns</span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={{ duration: 0.15 }}
className="absolute top-full right-0 z-20 mt-2 min-w-[180px] rounded-lg border border-table-border bg-card p-1 shadow-lg"
>
{hideableColumns.map((column) => (
<button
key={column.id}
onClick={() => toggleColumn(column.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted"
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded border transition-all",
visibleColumns.includes(column.id)
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40",
)}
>
{visibleColumns.includes(column.id) && (
<Check className="h-3 w-3" strokeWidth={3} />
)}
</div>
<span className="text-foreground">{column.header}</span>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
// Pagination Component
const TablePagination = ({
page,
pageSize,
totalItems,
pageSizeOptions = [5, 10, 20, 50],
onPageChange,
onPageSizeChange,
}: PaginationConfig) => {
const totalPages = Math.ceil(totalItems / pageSize);
const startItem = (page - 1) * pageSize + 1;
const endItem = Math.min(page * pageSize, totalItems);
const canGoPrev = page > 1;
const canGoNext = page < totalPages;
return (
<div className="flex flex-wrap items-center justify-between gap-4 border-table-border border-t bg-table-header px-4 py-3">
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<span>Rows per page:</span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange?.(Number(e.target.value))}
className="h-8 rounded border border-table-border bg-background px-2 text-foreground focus:border-primary focus:outline-none"
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</div>
<div className="flex items-center gap-1 text-muted-foreground text-sm">
<span>
{totalItems > 0
? `${startItem}-${endItem} of ${totalItems}`
: "0 items"}
</span>
</div>
<div className="flex items-center gap-1">
<motion.button
whileHover={{ scale: canGoPrev ? 1.05 : 1 }}
whileTap={{ scale: canGoPrev ? 0.95 : 1 }}
disabled={!canGoPrev}
onClick={() => onPageChange(1)}
className={cn(
"flex h-8 w-8 items-center justify-center rounded border border-table-border transition-colors",
canGoPrev
? "text-foreground hover:bg-muted"
: "cursor-not-allowed text-muted-foreground/40",
)}
>
<ChevronsLeft className="h-4 w-4" />
</motion.button>
<motion.button
whileHover={{ scale: canGoPrev ? 1.05 : 1 }}
whileTap={{ scale: canGoPrev ? 0.95 : 1 }}
disabled={!canGoPrev}
onClick={() => onPageChange(page - 1)}
className={cn(
"flex h-8 w-8 items-center justify-center rounded border border-table-border transition-colors",
canGoPrev
? "text-foreground hover:bg-muted"
: "cursor-not-allowed text-muted-foreground/40",
)}
>
<ChevronLeft className="h-4 w-4" />
</motion.button>
<div className="flex items-center gap-1 px-2">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (page <= 3) {
pageNum = i + 1;
} else if (page >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = page - 2 + i;
}
return (
<motion.button
key={pageNum}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => onPageChange(pageNum)}
className={cn(
"flex h-8 w-8 items-center justify-center rounded text-sm transition-colors",
page === pageNum
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{pageNum}
</motion.button>
);
})}
</div>
<motion.button
whileHover={{ scale: canGoNext ? 1.05 : 1 }}
whileTap={{ scale: canGoNext ? 0.95 : 1 }}
disabled={!canGoNext}
onClick={() => onPageChange(page + 1)}
className={cn(
"flex h-8 w-8 items-center justify-center rounded border border-table-border transition-colors",
canGoNext
? "text-foreground hover:bg-muted"
: "cursor-not-allowed text-muted-foreground/40",
)}
>
<ChevronRight className="h-4 w-4" />
</motion.button>
<motion.button
whileHover={{ scale: canGoNext ? 1.05 : 1 }}
whileTap={{ scale: canGoNext ? 0.95 : 1 }}
disabled={!canGoNext}
onClick={() => onPageChange(totalPages)}
className={cn(
"flex h-8 w-8 items-center justify-center rounded border border-table-border transition-colors",
canGoNext
? "text-foreground hover:bg-muted"
: "cursor-not-allowed text-muted-foreground/40",
)}
>
<ChevronsRight className="h-4 w-4" />
</motion.button>
</div>
</div>
);
};
// Animated Table Root
const AnimatedTableRoot = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { stickyHeader?: boolean }
>(({ className, stickyHeader, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative w-full overflow-hidden rounded-lg border border-table-border bg-card",
className,
)}
{...props}
/>
));
AnimatedTableRoot.displayName = "AnimatedTableRoot";
// Table Scroll Container
const TableScrollContainer = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { stickyHeader?: boolean }
>(({ className, stickyHeader, ...props }, ref) => (
<div
ref={ref}
className={cn("overflow-auto", stickyHeader && "max-h-[400px]", className)}
{...props}
/>
));
TableScrollContainer.displayName = "TableScrollContainer";
// Table Element
const TableElement = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
));
TableElement.displayName = "TableElement";
// Table Header
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement> & { sticky?: boolean }
>(({ className, sticky, ...props }, ref) => (
<thead
ref={ref}
className={cn(
"bg-table-header",
sticky && "sticky top-0 z-10 shadow-sm",
className,
)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
// Table Body
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
// Table Row
interface TableRowProps {
isSelected?: boolean;
striped?: boolean;
index?: number;
onClick?: () => void;
className?: string;
children?: React.ReactNode;
}
const TableRow = React.forwardRef<HTMLTableRowElement, TableRowProps>(
({ className, isSelected, striped, index = 0, onClick, children }, ref) => (
<motion.tr
ref={ref}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.2, delay: index * 0.03 }}
onClick={onClick}
className={cn(
"group border-table-border border-b transition-colors",
"hover:bg-table-row-hover",
isSelected && "bg-table-row-selected",
striped && index % 2 === 1 && "bg-table-row-stripe",
className,
)}
>
{children}
</motion.tr>
),
);
TableRow.displayName = "TableRow";
// Expanded Row
const ExpandedRow = ({
children,
colSpan,
}: {
children: React.ReactNode;
colSpan: number;
}) => (
<motion.tr
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="border-table-border border-b bg-muted/30"
>
<td colSpan={colSpan} className="p-0">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, delay: 0.05 }}
className="px-4 py-3"
>
{children}
</motion.div>
</td>
</motion.tr>
);
// Expand Button
const ExpandButton = ({
isExpanded,
onClick,
}: {
isExpanded: boolean;
onClick: () => void;
}) => (
<motion.button
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
animate={{ rotate: isExpanded ? 90 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</motion.button>
);
// Table Head Cell
interface TableHeadProps extends React.ThHTMLAttributes<HTMLTableCellElement> {
sortable?: boolean;
sortDirection?: SortDirection;
onSort?: () => void;
align?: "left" | "center" | "right";
}
const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>(
(
{
className,
children,
sortable,
sortDirection,
onSort,
align = "left",
...props
},
ref,
) => {
const alignClass = {
left: "text-left",
center: "text-center",
right: "text-right",
}[align];
return (
<th
ref={ref}
className={cn(
"h-12 px-4 font-medium text-muted-foreground",
alignClass,
sortable && "cursor-pointer select-none hover:text-foreground",
className,
)}
onClick={sortable ? onSort : undefined}
{...props}
>
<div
className={cn(
"flex items-center gap-2",
align === "center" && "justify-center",
align === "right" && "justify-end",
)}
>
<span>{children}</span>
{sortable && (
<motion.span
className="flex-shrink-0"
animate={sortDirection ? { scale: 1 } : { scale: 0.9 }}
>
{sortDirection === "asc" ? (
<ChevronUp className="h-4 w-4 animate-sort-bounce text-table-sort-active" />
) : sortDirection === "desc" ? (
<ChevronDown className="h-4 w-4 animate-sort-bounce text-table-sort-active" />
) : (
<ChevronsUpDown className="h-4 w-4 opacity-40 group-hover:opacity-70" />
)}
</motion.span>
)}
</div>
</th>
);
},
);
TableHead.displayName = "TableHead";
// Table Cell
interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement> {
align?: "left" | "center" | "right";
}
const TableCell = React.forwardRef<HTMLTableCellElement, TableCellProps>(
({ className, align = "left", ...props }, ref) => {
const alignClass = {
left: "text-left",
center: "text-center",
right: "text-right",
}[align];
return (
<td
ref={ref}
className={cn("p-4 align-middle", alignClass, className)}
{...props}
/>
);
},
);
TableCell.displayName = "TableCell";
// Checkbox Cell
interface CheckboxCellProps {
checked: boolean;
indeterminate?: boolean;
onChange: () => void;
}
const CheckboxCell = ({
checked,
indeterminate,
onChange,
}: CheckboxCellProps) => (
<div
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onChange();
}}
onKeyDown={(e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
onChange();
}
}}
className={cn(
"flex h-4 w-4 cursor-pointer items-center justify-center rounded border transition-all duration-150",
checked || indeterminate
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40 hover:border-muted-foreground",
)}
>
<AnimatePresence mode="wait">
{(checked || indeterminate) && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.1 }}
>
{indeterminate ? (
<div className="h-0.5 w-2 rounded-full bg-current" />
) : (
<Check className="h-3 w-3" strokeWidth={3} />
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
// Skeleton Row for Loading State
const SkeletonRow = ({
columns,
index,
}: {
columns: number;
index: number;
}) => (
<motion.tr
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.05 }}
className="border-table-border border-b"
>
<td colSpan={columns} className="p-4">
<div className="flex items-center gap-4">
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
<div className="flex-1 space-y-2">
<div
className="h-4 animate-pulse rounded bg-muted"
style={{ width: `${60 + Math.random() * 30}%` }}
/>
</div>
{Array.from({ length: columns - 2 }).map((_, i) => (
<div
key={i}
className="h-4 animate-pulse rounded bg-muted"
style={{ width: `${40 + Math.random() * 40}px` }}
/>
))}
</div>
</td>
</motion.tr>
);
// Empty State
const EmptyState = ({
message,
colSpan,
}: {
message: React.ReactNode;
colSpan: number;
}) => (
<motion.tr
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<td colSpan={colSpan} className="h-32 text-center">
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
{message || "No data available"}
</div>
</td>
</motion.tr>
);
// Main AnimatedTable Component
export function AnimatedTable<T extends { id: string | number }>({
data,
columns,
selectable = false,
selectedIds = [],
onSelectionChange,
onRowClick,
sortColumn,
sortDirection,
onSort,
striped = false,
stickyHeader = false,
className,
emptyMessage,
loading = false,
loadingRows = 5,
// New features
searchable = false,
searchValue = "",
onSearchChange,
searchPlaceholder,
pagination,
expandable = false,
renderExpandedRow,
columnVisibility = false,
visibleColumns: controlledVisibleColumns,
onVisibleColumnsChange,
}: AnimatedTableProps<T>) {
const [expandedRows, setExpandedRows] = React.useState<Set<string | number>>(
new Set(),
);
const [internalVisibleColumns, setInternalVisibleColumns] = React.useState<
string[]
>(columns.map((col) => col.id));
const visibleColumns = controlledVisibleColumns || internalVisibleColumns;
const setVisibleColumns = onVisibleColumnsChange || setInternalVisibleColumns;
const displayedColumns = columns.filter((col) =>
visibleColumns.includes(col.id),
);
const allSelected = data.length > 0 && selectedIds.length === data.length;
const someSelected =
selectedIds.length > 0 && selectedIds.length < data.length;
const handleSelectAll = () => {
if (onSelectionChange) {
if (allSelected) {
onSelectionChange([]);
} else {
onSelectionChange(data.map((row) => row.id));
}
}
};
const handleSelectRow = (id: string | number) => {
if (onSelectionChange) {
if (selectedIds.includes(id)) {
onSelectionChange(
selectedIds.filter((selectedId) => selectedId !== id),
);
} else {
onSelectionChange([...selectedIds, id]);
}
}
};
const handleSort = (columnId: string) => {
if (onSort) {
let newDirection: SortDirection;
if (sortColumn !== columnId) {
newDirection = "asc";
} else if (sortDirection === "asc") {
newDirection = "desc";
} else if (sortDirection === "desc") {
newDirection = null;
} else {
newDirection = "asc";
}
onSort(columnId, newDirection);
}
};
const toggleRowExpanded = (id: string | number) => {
setExpandedRows((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const extraColumns = (selectable ? 1 : 0) + (expandable ? 1 : 0);
const totalColumns = displayedColumns.length + extraColumns;
const showToolbar = searchable || columnVisibility;
return (
<AnimatedTableRoot className={className}>
{/* Toolbar */}
{showToolbar && (
<div className="flex flex-wrap items-center justify-between gap-3 border-table-border border-b bg-table-header p-3">
{searchable && (
<div className="w-full sm:w-64">
<TableSearch
value={searchValue}
onChange={onSearchChange || (() => {})}
placeholder={searchPlaceholder}
/>
</div>
)}
<div className="flex items-center gap-2">
{columnVisibility && (
<ColumnVisibilityDropdown
columns={columns}
visibleColumns={visibleColumns}
onChange={setVisibleColumns}
/>
)}
</div>
</div>
)}
{/* Table */}
<TableScrollContainer stickyHeader={stickyHeader}>
<TableElement>
<TableHeader sticky={stickyHeader}>
<tr className="border-table-border border-b">
{expandable && <TableHead className="w-10" />}
{selectable && (
<TableHead className="w-12">
<CheckboxCell
checked={allSelected}
indeterminate={someSelected}
onChange={handleSelectAll}
/>
</TableHead>
)}
{displayedColumns.map((column) => (
<TableHead
key={column.id}
sortable={column.sortable && !!onSort}
sortDirection={
sortColumn === column.id ? sortDirection : null
}
onSort={() => handleSort(column.id)}
align={column.align}
style={{ width: column.width }}
>
{column.header}
</TableHead>
))}
</tr>
</TableHeader>
<TableBody>
<AnimatePresence mode="popLayout">
{loading ? (
Array.from({ length: loadingRows }).map((_, index) => (
<SkeletonRow
key={`skeleton-${index}`}
columns={totalColumns}
index={index}
/>
))
) : data.length === 0 ? (
<EmptyState message={emptyMessage} colSpan={totalColumns} />
) : (
data.map((row, index) => (
<React.Fragment key={row.id}>
<TableRow
isSelected={selectedIds.includes(row.id)}
striped={striped}
index={index}
onClick={() => onRowClick?.(row)}
className={onRowClick ? "cursor-pointer" : undefined}
>
{expandable && (
<TableCell className="w-10">
<ExpandButton
isExpanded={expandedRows.has(row.id)}
onClick={() => toggleRowExpanded(row.id)}
/>
</TableCell>
)}
{selectable && (
<TableCell className="w-12">
<CheckboxCell
checked={selectedIds.includes(row.id)}
onChange={() => handleSelectRow(row.id)}
/>
</TableCell>
)}
{displayedColumns.map((column) => (
<TableCell key={column.id} align={column.align}>
{column.cell
? column.cell(row, index)
: column.accessorKey
? String(row[column.accessorKey] ?? "")
: null}
</TableCell>
))}
</TableRow>
<AnimatePresence>
{expandable &&
expandedRows.has(row.id) &&
renderExpandedRow && (
<ExpandedRow colSpan={totalColumns}>
{renderExpandedRow(row)}
</ExpandedRow>
)}
</AnimatePresence>
</React.Fragment>
))
)}
</AnimatePresence>
</TableBody>
</TableElement>
</TableScrollContainer>
{/* Pagination */}
{pagination && <TablePagination {...pagination} />}
</AnimatedTableRoot>
);
}
export {
AnimatedTableRoot,
CheckboxCell,
ColumnVisibilityDropdown,
EmptyState,
ExpandButton,
ExpandedRow,
SkeletonRow,
TableBody,
TableCell,
TableElement,
TableHead,
TableHeader,
TablePagination,
TableRow,
TableScrollContainer,
TableSearch,
};API Reference
AnimatedTable
The main animated table component with comprehensive features.
Prop
Type
ColumnDef
Configuration for table columns.
Prop
Type
PaginationConfig
Configuration for pagination functionality.
Prop
Type
How is this guide?