Table
Overview
The Table component provides a way to display data in a structured format with support for sorting, pagination, and customizable styling. It's built on top of Radix UI's table primitives for accessibility and performance.
Preview
- Preview
- Code
import { useState } from 'react';
import { Table } from './components/ui';
function TableExample() {
const [sortConfig, setSortConfig] = useState({ key: 'name', direction: 'asc' });
const [currentPage, setCurrentPage] = useState(1);
const totalPages = 3;
const data = [
{ id: 1, name: 'John Doe', email: 'john@example.com', status: 'Active' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', status: 'Inactive' },
{ id: 3, name: 'Bob Johnson', email: 'bob@example.com', status: 'Active' },
];
const handleSort = (key, direction) => {
setSortConfig({ key, direction });
// Implement your sorting logic here
};
const handlePageChange = (page) => {
setCurrentPage(page);
// Fetch or update data for the new page
};
return (
<Table
headings={[
{ label: 'Name', key: 'name', sort: 'asc' },
{ label: 'Email', key: 'email', sort: 'asc' },
{ label: 'Status', key: 'status', sort: 'asc' },
]}
data={data}
applySort={handleSort}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
);
}
Installation
- npm
- yarn
- pnpm
- manual
npx @mindfiredigital/ignix-ui add table
yarn @mindfiredigital/ignix-ui add table
pnpm @mindfiredigital/ignix-ui add table
"use client";
import React, { useCallback, useState } from "react";
import { AnimatePresence, motion, useAnimation } from "framer-motion";
import { TriangleDownIcon, TriangleUpIcon } from "@radix-ui/react-icons";
import { Flex, Table as RadixTable, Theme } from "@radix-ui/themes";
import { cn } from "../../../utils/cn";
import { Pagination, type PaginationProps } from "./pagination";
export type TableSortBy = "asc" | "desc";
export interface TableProps {
headings: Array<{
label: React.ReactNode;
key: string;
sort: TableSortBy;
}>;
data: Array<Record<string, React.ReactNode>>;
applySort: (key: string, value: TableSortBy) => void;
variant?: "surface" | "ghost";
headingVariant?: "row" | "column";
size?: "sm" | "md" | "lg";
animationVariant?: "fade" | "slide" | "scale" | "flip" | "elastic";
showHoverEffects?: boolean;
showStripes?: boolean;
showBorders?: boolean;
glow?: boolean;
rowKeyExtractor?: (row: Record<string, React.ReactNode>, index: number) => string;
}
const contentAnimations = {
fade: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.4, ease: "easeInOut" },
},
slide: {
initial: { opacity: 0, y: 20, scale: 0.95 },
animate: { opacity: 1, y: 0, scale: 1 },
exit: { opacity: 0, y: -20, scale: 0.95 },
transition: { duration: 0.5, ease: "easeInOut" },
},
scale: {
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.8 },
transition: { duration: 0.45, ease: "easeInOut" },
},
flip: {
initial: { opacity: 0, rotateX: -90 },
animate: { opacity: 1, rotateX: 0 },
exit: { opacity: 0, rotateX: 90 },
transition: { duration: 0.6, ease: "easeInOut" },
},
elastic: {
initial: { opacity: 0, scale: 0.3, rotate: -10 },
animate: { opacity: 1, scale: 1, rotate: 0 },
exit: { opacity: 0, scale: 0.3, rotate: 10 },
transition: { type: "spring", stiffness: 300, damping: 20, duration: 0.8 },
},
};
const sizeConfigs = {
sm: { fontSize: "text-sm", padding: "py-1 px-2", headingFontSize: "text-sm" },
md: { fontSize: "text-base", padding: "py-2 px-3", headingFontSize: "text-base" },
lg: { fontSize: "text-lg", padding: "py-3 px-4", headingFontSize: "text-lg" },
};
function getRowKey(
row: Record<string, React.ReactNode>,
index: number,
headings: Array<{ key: string }>,
extractor?: (row: Record<string, React.ReactNode>, index: number) => string
) {
if (extractor) return extractor(row, index);
// Default stable key: combine cell values
return headings.map((h) => String(row[h.key] ?? "")).join("|") + `__${index}`;
}
type AnimatedContentProps = {
content: React.ReactNode;
changeKey: string;
animationVariant: keyof typeof contentAnimations;
};
function AnimatedContent({ content, changeKey, animationVariant }: AnimatedContentProps) {
const controls = useAnimation();
React.useEffect(() => {
controls.set(contentAnimations[animationVariant].initial);
controls.start({
...contentAnimations[animationVariant].animate,
transition: contentAnimations[animationVariant].transition,
});
}, [changeKey, animationVariant, controls]);
return (
<motion.div animate={controls} initial="initial" exit="exit" role="cell" className="min-w-0">
{content}
</motion.div>
);
}
function ColumnTable(props: TableProps) {
const {
headings,
data,
applySort,
variant = "surface",
size = "md",
animationVariant = "fade",
showHoverEffects = true,
showStripes = true,
showBorders = true,
glow = false,
rowKeyExtractor,
} = props;
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
const [hoveredColumn, setHoveredColumn] = useState<string | null>(null);
const config = sizeConfigs[size];
const handleSort = useCallback(
(ev: React.MouseEvent<HTMLTableCellElement, MouseEvent>) => {
const target = ev.currentTarget;
const key = target.getAttribute("data-key");
const currentSort = target.getAttribute("data-sort");
if (key && currentSort) {
applySort(key, currentSort === "asc" ? "desc" : "asc");
}
},
[applySort]
);
return (
<div className="relative w-full rounded-lg overflow-hidden shadow-md">
{glow && (
<div className="absolute inset-0 rounded-lg bg-gradient-to-r from-primary to-primary opacity-30 blur-lg pointer-events-none animate-pulse" />
)}
<RadixTable.Root
className={cn(
"w-full border-collapse",
variant === "ghost" ? "border border-transparent" : "border border-primary dark:border-gray-700"
)}
>
<RadixTable.Header className="bg-gray-50 dark:bg-gray-800">
<RadixTable.Row>
{headings.map(({ label, key, sort }) => (
<RadixTable.ColumnHeaderCell
key={key}
data-key={key}
data-sort={sort}
onClick={handleSort}
onMouseEnter={() => setHoveredColumn(key)}
onMouseLeave={() => setHoveredColumn(null)}
className={cn(
"relative select-none cursor-pointer px-3 first:rounded-l-lg last:rounded-r-lg text-left font-semibold",
config.padding,
config.headingFontSize,
"border-r last:border-r-0 text-gray-700 dark:text-gray-300",
showHoverEffects && hoveredColumn === key ? "bg-gray-100 dark:bg-gray-700" : "",
glow ? "text-primary" : "",
showBorders && "border-gray-300 dark:border-gray-600"
)}
>
<Flex justify="between" align="center">
<div className="truncate">{label}</div>
<div className="flex items-center gap-1 shrink-0">
{sort === "asc" ? (
<TriangleUpIcon className="w-4 h-4 text-primary" />
) : (
<TriangleDownIcon className="w-4 h-4 text-primary" />
)}
</div>
</Flex>
</RadixTable.ColumnHeaderCell>
))}
</RadixTable.Row>
</RadixTable.Header>
<RadixTable.Body>
<AnimatePresence mode="wait">
{data.map((row, index) => {
const rowKey = getRowKey(row, index, headings, rowKeyExtractor);
return (
<motion.tr
key={rowKey}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className={cn(
"cursor-default",
showStripes && index % 2 === 1 ? "bg-gray-50 dark:bg-gray-800" : "",
showHoverEffects ? "hover:bg-primary dark:hover:bg-primary" : "",
showBorders ? "border-b border-gray-200 dark:border-gray-700" : ""
)}
onMouseEnter={() => setHoveredRow(index)}
onMouseLeave={() => setHoveredRow(null)}
style={{ zIndex: hoveredRow === index ? 10 : "auto" }}
>
{headings.map(({ key }) => {
return (
<RadixTable.Cell
key={`${rowKey}-${key}`}
className={cn(
"select-text truncate max-w-[200px] whitespace-nowrap px-3",
config.padding,
config.fontSize,
showBorders ? "border-r border-gray-200 dark:border-gray-700" : "",
showHoverEffects && hoveredRow === index ? "font-semibold" : ""
)}
>
<AnimatedContent
animationVariant={animationVariant}
changeKey={`${rowKey}-${key}-${String(row[key] ?? "")}`}
content={row[key] ?? ""}
/>
</RadixTable.Cell>
);
})}
</motion.tr>
);
})}
</AnimatePresence>
</RadixTable.Body>
</RadixTable.Root>
</div>
);
}
function RowTable(props: TableProps) {
// Similar to ColumnTable, but transposed heading/data cells for Row Headings style.
// Implement if needed or fallback to ColumnTable
// For brevity, not implemented here.
return <ColumnTable {...props} />;
}
export function Table(props: TableProps & Partial<PaginationProps> & { currentPage: number; totalPages: number; onPageChange: (page: number) => void }) {
const {
headingVariant = "column",
currentPage,
totalPages,
onPageChange,
...rest
} = props;
const TableContent = headingVariant === "row" ? RowTable : ColumnTable;
return (
<Theme radius="large" appearance="inherit" {...rest}>
<Flex direction="column" align="center" gap="6" className="w-full">
<TableContent {...rest} />
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={onPageChange} />
</Flex>
</Theme>
);
}
import { Text } from "@radix-ui/themes";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import React, { useCallback, useMemo } from "react";
import { Button } from "../../button";
export interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
siblingCount?: number;
}
const DOTS = "...";
const range = (start: number, end: number) => {
const length = end - start + 1;
return Array.from({ length }, (_, idx) => idx + start);
};
const usePagination = ({
currentPage,
totalPages,
siblingCount = 1,
}: Omit<PaginationProps, "onPageChange">) => {
const paginationRange = useMemo(() => {
const totalPageNumbers = siblingCount + 5;
if (totalPageNumbers >= totalPages) {
return range(1, totalPages);
}
const leftSiblingIndex = Math.max(currentPage - siblingCount, 1);
const rightSiblingIndex = Math.min(currentPage + siblingCount, totalPages);
const shouldShowLeftDots = leftSiblingIndex > 2;
const shouldShowRightDots = rightSiblingIndex < totalPages - 2;
const firstPageIndex = 1;
const lastPageIndex = totalPages;
if (!shouldShowLeftDots && shouldShowRightDots) {
const leftItemCount = 3 + 2 * siblingCount;
const leftRange = range(1, leftItemCount);
return [...leftRange, DOTS, totalPages];
}
if (shouldShowLeftDots && !shouldShowRightDots) {
const rightItemCount = 3 + 2 * siblingCount;
const rightRange = range(totalPages - rightItemCount + 1, totalPages);
return [firstPageIndex, DOTS, ...rightRange];
}
if (shouldShowLeftDots && shouldShowRightDots) {
const middleRange = range(leftSiblingIndex, rightSiblingIndex);
return [firstPageIndex, DOTS, ...middleRange, DOTS, lastPageIndex];
}
return range(1, totalPages);
}, [totalPages, siblingCount, currentPage]);
return paginationRange;
};
export const Pagination = React.memo(function Pagination({
currentPage,
totalPages,
onPageChange,
siblingCount = 1,
}: PaginationProps) {
const paginationRange = usePagination({ currentPage, totalPages, siblingCount });
const handleFirstPage = useCallback(() => onPageChange(1), [onPageChange]);
const handlePreviousPage = useCallback(() => onPageChange(currentPage - 1), [currentPage, onPageChange]);
const handleNextPage = useCallback(() => onPageChange(currentPage + 1), [currentPage, onPageChange]);
const handleLastPage = useCallback(() => onPageChange(totalPages), [totalPages, onPageChange]);
if (totalPages <= 1) return null;
const isFirstPage = currentPage === 1;
const isLastPage = currentPage === totalPages;
return (
<div className="flex justify-center gap-2 py-4">
<Button variant="outline" size="md" onClick={handleFirstPage} disabled={isFirstPage} aria-label="First page">
<ChevronsLeft size={16} />
</Button>
<Button variant="outline" size="md" onClick={handlePreviousPage} disabled={isFirstPage} aria-label="Previous page">
<ChevronLeft size={16} />
</Button>
{paginationRange?.map((pageNumber, index) => {
if (pageNumber === DOTS) {
return (
<Text key={`${DOTS}-${index}`} size="2" className="px-2">
…
</Text>
);
}
return (
<Button
key={pageNumber}
variant={pageNumber === currentPage ? "default" : "ghost"}
size="md"
onClick={() => onPageChange(pageNumber as number)}
disabled={pageNumber === currentPage}
aria-current={pageNumber === currentPage ? "page" : undefined}
>
{pageNumber}
</Button>
);
})}
<Button variant="outline" size="md" onClick={handleNextPage} disabled={isLastPage} aria-label="Next page">
<ChevronRight size={16} />
</Button>
<Button variant="outline" size="md" onClick={handleLastPage} disabled={isLastPage} aria-label="Last page">
<ChevronsRight size={16} />
</Button>
</div>
);
});
Pagination.displayName = "Pagination";
export { Pagination };
Usage
Basic Usage
import { Table } from './components/ui';
function MyTable() {
const [sortConfig, setSortConfig] = useState({ key: 'name', direction: 'asc' });
const data = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
];
const handleSort = (key, direction) => {
setSortConfig({ key, direction });
// Implement sorting logic
};
return (
<Table
headings={[
{ label: 'Name', key: 'name', sort: 'asc' },
{ label: 'Email', key: 'email', sort: 'asc' },
]}
data={data}
applySort={handleSort}
/>
);
}
With Pagination
function PaginatedTable() {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = 5;
// ... rest of your component code ...
return (
<Table
// ... other props ...
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
);
}
Variants
- Preview
- Code
<Table
headings={headings}
data={data}
applySort={handleSort}
size="md"
animationVariant="fade"
variant="surface"
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>