shadimage
jolyui
uianimated-tablesource: ok · hit

Animated Table

npx shadcn add "https://www.jolyui.dev/r/animated-table.json"
free

Where it goes

data-table

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
iconslucide-react
animationmotion

Files1 · source cached 10/15/2026, 10:25:41 AM

ui/animated-table.tsx
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="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 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="absolute top-1/2 right-2 -translate-y-1/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,
};

More from jolyui

Browse all components in this library →