shadimage
nekode
uidatatablesource: ok · hit

DataTable

Advanced table component with sorting, selection, pagination, filtering, row expansion, and more.

npx shadcn add "https://ui.nekode.id/r/datatable.json"
free

Where it goes

data-table

Interaction

dropdown

Use cases

dashboard

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

Dependencies

Bundle weight · light
ui-primitives@base-ui/react
iconslucide-react

Registry dependencies

checkboxlabelbuttonpopovercontext-menuseparatorselectdropdown-menutable

Files11 · source cached 10/8/2026, 1:40:57 PM

src/registry/ui/datatable/DataTable.tsx
"use client";

import * as React from "react";
import { Table, TableBody } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { DataTableEmptyState } from "./DataTableEmptyState";
import { DataTableFooter } from "./DataTableFooter";
import { DataTableHeader } from "./DataTableHeader";
import { DataTableLoader } from "./DataTableLoader";
import { DataTablePagination } from "./DataTablePagination";
import { DataTableRow } from "./DataTableRow";
import type {
  BaseDataTableProps,
  DataTableColumn,
  RequiredSortProps,
  SortDirection,
  SortProps,
} from "./types";
import {
  computeOrderedColumns,
  computePinOffsets,
  getColumnKey,
  getRecordId,
  useMergedRef,
} from "./utils";

export type {
  DataTableColumn,
  DataTablePaginationProps,
  RowContextMenuItem,
  SortDirection,
  SortStatus,
} from "./types";

const SELECTION_COL_WIDTH = 44;
const EXPAND_COL_WIDTH = 40;
const DEFAULT_PINNED_WIDTH = 150;

export function DataTable<
  T extends Record<string, any>,
  Cols extends readonly DataTableColumn<T>[] = DataTableColumn<T>[],
>(
  props: BaseDataTableProps<T> & {
    columns: Cols;
  } & (Cols[number]["sortable"] extends true
      ? RequiredSortProps<T>
      : SortProps<T>),
) {
  const {
    data,
    fetching = false,
    columns,
    idAccessor,
    selectedRecords,
    onSelectedRecordsChange,
    height,
    rowContextMenu,
    pagination,
    noRecordsText = "No records to show",
    highlightOnHover = true,
    onRowClick,
    className,
    variant = "bordered",
    rowClassName,
    rowStyle,
    headerClassName,
    headerStyle,
    renderRowSubContent,
    rowExpandable,
    expandOnRowClick = true,
    singleExpand = false,
    expandedRecordIds,
    onExpandedRecordIdsChange,
    headerRef,
    bodyRef,
    footerRef,
    tableRef,
    scrollViewportRef,
    rowRef,
    sortStatus,
    onSortStatusChange,
  } = props as BaseDataTableProps<T> & SortProps<T>;

  const selectable = !!onSelectedRecordsChange;
  const expandable = !!renderRowSubContent;
  const sticky = !!height;

  /* ------------------------------- Selection ------------------------------ */

  const selectedIds = React.useMemo(
    () =>
      new Set((selectedRecords ?? []).map((r) => getRecordId(r, idAccessor))),
    [selectedRecords, idAccessor],
  );

  const pageSelectedCount = React.useMemo(
    () =>
      data.filter((r) => selectedIds.has(getRecordId(r, idAccessor))).length,
    [data, selectedIds, idAccessor],
  );
  const allChecked = data.length > 0 && pageSelectedCount === data.length;
  const someChecked = pageSelectedCount > 0 && !allChecked;

  // Anchor for shift-click ranges, tracked by id (not index) so it can't
  // point at the wrong row after paging, sorting or refetching.
  const [lastSelectionId, setLastSelectionId] =
    React.useState<React.Key | null>(null);

  const handleToggleSelect = React.useCallback(
    (record: T, rowIndex: number, shiftKey: boolean) => {
      if (!onSelectedRecordsChange) return;
      const id = getRecordId(record, idAccessor);

      const lastSelectionIndex =
        lastSelectionId === null
          ? -1
          : data.findIndex(
              (r) => getRecordId(r, idAccessor) === lastSelectionId,
            );

      if (shiftKey && lastSelectionIndex !== -1) {
        const [start, end] =
          lastSelectionIndex < rowIndex
            ? [lastSelectionIndex, rowIndex]
            : [rowIndex, lastSelectionIndex];
        const rangeRecords = data.slice(start, end + 1);
        const willSelect = !selectedIds.has(id);
        if (willSelect) {
          const merged = [...(selectedRecords ?? [])];
          const existing = new Set(
            merged.map((r) => getRecordId(r, idAccessor)),
          );
          for (const r of rangeRecords) {
            const rid = getRecordId(r, idAccessor);
            if (!existing.has(rid)) {
              merged.push(r);
              existing.add(rid);
            }
          }
          onSelectedRecordsChange(merged);
        } else {
          const rangeIds = new Set(
            rangeRecords.map((r) => getRecordId(r, idAccessor)),
          );
          onSelectedRecordsChange(
            (selectedRecords ?? []).filter(
              (r) => !rangeIds.has(getRecordId(r, idAccessor)),
            ),
          );
        }
      } else if (selectedIds.has(id)) {
        onSelectedRecordsChange(
          (selectedRecords ?? []).filter(
            (r) => getRecordId(r, idAccessor) !== id,
          ),
        );
      } else {
        onSelectedRecordsChange([...(selectedRecords ?? []), record]);
      }
      setLastSelectionId(id);
    },
    [
      onSelectedRecordsChange,
      selectedRecords,
      selectedIds,
      idAccessor,
      data,
      lastSelectionId,
    ],
  );

  const handleToggleAll = React.useCallback(() => {
    if (!onSelectedRecordsChange) return;
    if (allChecked) {
      const idsOnPage = new Set(data.map((r) => getRecordId(r, idAccessor)));
      onSelectedRecordsChange(
        (selectedRecords ?? []).filter(
          (r) => !idsOnPage.has(getRecordId(r, idAccessor)),
        ),
      );
    } else {
      const merged = [...(selectedRecords ?? [])];
      const existing = new Set(merged.map((r) => getRecordId(r, idAccessor)));
      for (const r of data) {
        const id = getRecordId(r, idAccessor);
        if (!existing.has(id)) {
          merged.push(r);
          existing.add(id);
        }
      }
      onSelectedRecordsChange(merged);
    }
  }, [onSelectedRecordsChange, allChecked, data, selectedRecords, idAccessor]);

  /* ------------------------------- Expansion ------------------------------ */

  const [internalExpanded, setInternalExpanded] = React.useState<
    Set<React.Key>
  >(new Set());
  const isExpandControlled = expandedRecordIds !== undefined;
  const expandedSet = React.useMemo(
    () =>
      new Set(
        isExpandControlled ? expandedRecordIds : Array.from(internalExpanded),
      ),
    [isExpandControlled, expandedRecordIds, internalExpanded],
  );

  const toggleExpand = React.useCallback(
    (record: T) => {
      const id = getRecordId(record, idAccessor);
      let next: Set<React.Key>;
      if (singleExpand) {
        next = expandedSet.has(id) ? new Set() : new Set([id]);
      } else {
        next = new Set(expandedSet);
        if (next.has(id)) next.delete(id);
        else next.add(id);
      }
      if (isExpandControlled) onExpandedRecordIdsChange?.(Array.from(next));
      else setInternalExpanded(next);
    },
    [
      expandedSet,
      singleExpand,
      isExpandControlled,
      onExpandedRecordIdsChange,
      idAccessor,
    ],
  );

  /* --------------------------------- Sort ---------------------------------- */

  const handleSort = React.useCallback(
    (col: DataTableColumn<T>) => {
      if (!col.sortable || !onSortStatusChange) return;
      const isCurrent = sortStatus?.columnAccessor === col.accessor;
      const nextDirection: SortDirection =
        isCurrent && sortStatus?.direction === "asc" ? "desc" : "asc";
      onSortStatusChange({
        columnAccessor: col.accessor,
        direction: nextDirection,
      });
    },
    [onSortStatusChange, sortStatus],
  );

  /* -------------------------------- Pinning -------------------------------- */

  const orderedColumns = React.useMemo(
    () => computeOrderedColumns(columns),
    [columns],
  );
  const leadingGutter =
    (selectable ? SELECTION_COL_WIDTH : 0) +
    (expandable ? EXPAND_COL_WIDTH : 0);

  const { leftOffsets, rightOffsets, leftPinnedCols, rightPinnedCols } =
    React.useMemo(
      () =>
        computePinOffsets(orderedColumns, leadingGutter, DEFAULT_PINNED_WIDTH),
      [orderedColumns, leadingGutter],
    );

  const pinnedCellStyle = React.useCallback(
    (col: DataTableColumn<T>): React.CSSProperties => {
      const key = getColumnKey(col);
      if (col.pinned === "left")
        return { position: "sticky", left: leftOffsets[key] };
      if (col.pinned === "right")
        return { position: "sticky", right: rightOffsets[key] };
      return {};
    },
    [leftOffsets, rightOffsets],
  );

  const pinnedCellClassName = React.useCallback(
    (
      col: DataTableColumn<T>,
      zIndexClass: string,
      bgClassName: string = "bg-background",
    ) => {
      const isLastLeft =
        leftPinnedCols.length > 0 &&
        leftPinnedCols[leftPinnedCols.length - 1] === col;
      const isFirstRight =
        rightPinnedCols.length > 0 && rightPinnedCols[0] === col;
      return cn(
        col.pinned && "sticky",
        col.pinned && bgClassName,
        col.pinned && zIndexClass,
        col.pinned === "left" &&
          isLastLeft &&
          "shadow-[2px_0_4px_-2px_rgba(0,0,0,0.15)]",
        col.pinned === "right" &&
          isFirstRight &&
          "shadow-[-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
      );
    },
    [leftPinnedCols, rightPinnedCols],
  );

  /* ------------------------------ Scroll shadow ----------------------------- */

  const scrollRef = React.useRef<HTMLDivElement>(null);
  const mergedScrollRef = useMergedRef(scrollRef, scrollViewportRef);
  const [scrollShadow, setScrollShadow] = React.useState({
    left: false,
    right: false,
  });

  const updateScrollShadow = React.useCallback(() => {
    const el = scrollRef.current;
    if (!el) return;
    setScrollShadow({
      left: el.scrollLeft > 1,
      right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
    });
  }, []);

  React.useEffect(() => {
    updateScrollShadow();
    window.addEventListener("resize", updateScrollShadow);
    return () => window.removeEventListener("resize", updateScrollShadow);
  }, [updateScrollShadow, data, orderedColumns]);

  /* ------------------------------ Pagination -------------------------------- */

  const totalCols =
    orderedColumns.length + (selectable ? 1 : 0) + (expandable ? 1 : 0);

  /* --------------------------------- Render --------------------------------- */

  return (
    <div
      className={cn(
        "rounded-md",
        variant === "bordered" && "border",
        className,
      )}
    >
      <div className="relative">
        <DataTableLoader fetching={fetching} />
        <div
          aria-hidden
          className={cn(
            "pointer-events-none absolute inset-y-0 left-0 z-25 w-6 bg-gradient-to-r from-black/10 to-transparent opacity-0 transition-opacity dark:from-white/10",
            scrollShadow.left && "opacity-100",
          )}
        />
        <div
          aria-hidden
          className={cn(
            "pointer-events-none absolute inset-y-0 right-0 z-25 w-6 bg-gradient-to-l from-black/10 to-transparent opacity-0 transition-opacity dark:from-white/10",
            scrollShadow.right && "opacity-100",
          )}
        />

        <div
          ref={mergedScrollRef}
          onScroll={updateScrollShadow}
          style={height ? { height, overflow: "auto" } : { overflow: "auto" }}
        >
          <Table
            ref={tableRef}
            className="min-w-full"
            style={{
              tableLayout: columns.some((c) => c.width) ? "fixed" : "auto",
            }}
          >
            <DataTableHeader<T>
              ref={headerRef}
              columns={orderedColumns}
              selectable={selectable}
              expandable={expandable}
              selectionColWidth={SELECTION_COL_WIDTH}
              expandColWidth={EXPAND_COL_WIDTH}
              selectionChecked={allChecked}
              selectionIndeterminate={someChecked}
              onSelectionChange={handleToggleAll}
              sortStatus={sortStatus}
              onSort={handleSort}
              getColumnKey={getColumnKey}
              pinnedCellStyle={pinnedCellStyle}
              pinnedCellClassName={pinnedCellClassName}
              headerClassName={headerClassName}
              headerStyle={headerStyle}
              sticky={sticky}
              variant={variant}
            />

            <TableBody ref={bodyRef}>
              {data.length === 0 ? (
                <DataTableEmptyState
                  text={fetching ? "" : noRecordsText}
                  colSpan={totalCols}
                />
              ) : (
                data.map((record, rowIndex) => {
                  const rowId = getRecordId(record, idAccessor);
                  const isSelected = selectedIds.has(rowId);
                  const isExpanded = expandedSet.has(rowId);
                  const canExpand =
                    expandable &&
                    (rowExpandable ? rowExpandable(record, rowIndex) : true);
                  const contextMenuItems = rowContextMenu?.(record, rowIndex);
                  const resolvedRowClassName =
                    typeof rowClassName === "function"
                      ? rowClassName(record, rowIndex)
                      : rowClassName;
                  const resolvedRowStyle =
                    typeof rowStyle === "function"
                      ? rowStyle(record, rowIndex)
                      : rowStyle;

                  return (
                    <DataTableRow<T>
                      key={rowId}
                      record={record}
                      rowIndex={rowIndex}
                      columns={orderedColumns}
                      selectable={selectable}
                      expandable={expandable}
                      selectionColWidth={SELECTION_COL_WIDTH}
                      expandColWidth={EXPAND_COL_WIDTH}
                      isSelected={isSelected}
                      isExpanded={isExpanded}
                      canExpand={canExpand}
                      renderSubContent={renderRowSubContent}
                      onToggleSelect={handleToggleSelect}
                      onToggleExpand={toggleExpand}
                      onRowClick={onRowClick}
                      expandOnRowClick={expandOnRowClick}
                      highlightOnHover={highlightOnHover}
                      rowClassName={resolvedRowClassName}
                      rowStyle={resolvedRowStyle}
                      contextMenuItems={contextMenuItems}
                      getColumnKey={getColumnKey}
                      pinnedCellStyle={pinnedCellStyle}
                      pinnedCellClassName={pinnedCellClassName}
                      leadingGutter={leadingGutter}
                      totalCols={totalCols}
                      variant={variant}
                      rowRef={rowRef}
                    />
                  );
                })
              )}
            </TableBody>

            <DataTableFooter
              columns={orderedColumns}
              data={data}
              selectable={selectable}
              expandable={expandable}
              selectionColWidth={SELECTION_COL_WIDTH}
              expandColWidth={EXPAND_COL_WIDTH}
              getColumnKey={getColumnKey}
              pinnedCellStyle={pinnedCellStyle}
              pinnedCellClassName={pinnedCellClassName}
              sticky={sticky}
              variant={variant}
            />
          </Table>
        </div>
      </div>

      {pagination && (
        <DataTablePagination
          pagination={pagination}
          variant={variant}
          sticky={sticky}
          footerRef={footerRef}
          fetching={fetching}
        />
      )}
    </div>
  );
}

export default DataTable;
src/registry/ui/datatable/DataTableAction.tsx
"use client";

import { MoreVertical } from "lucide-react";
import type * as React from "react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { RowContextMenuItem } from "./types";

export interface DataTableActionProps {
  /** Menu items — same shape as `rowContextMenu`'s per-row return value, so
   * you can reuse the exact same list for both the right-click context menu
   * and this button. */
  items: RowContextMenuItem[];
  /** Accessible label for the trigger button. */
  label?: string;
  /** Icon shown on the trigger button. Defaults to a vertical 3-dot icon. */
  icon?: React.ReactNode;
  className?: string;
  align?: "start" | "center" | "end";
  side?: "top" | "bottom" | "left" | "right";
}

/**
 * A "kebab" (3-dot) button that opens a menu — meant for a table's actions
 * column. Typically used together with (or instead of) `rowContextMenu`:
 *
 *   {
 *     accessor: "actions",
 *     title: "",
 *     width: 60,
 *     render: (record) => (
 *       <DataTableAction
 *         items={[
 *           { key: "edit", label: "Edit", onClick: () => editUser(record) },
 *           { key: "delete", label: "Delete", danger: true, onClick: () => deleteUser(record) },
 *         ]}
 *       />
 *     ),
 *   }
 *
 * Stops click propagation so it's safe to use inside a row that also has
 * `onRowClick` / expandable rows without accidentally triggering those.
 */
export function DataTableAction({
  items,
  label = "Open menu",
  icon,
  className,
  align = "end",
  side = "bottom",
}: DataTableActionProps) {
  if (!items.length) return null;

  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        className={cn(
          "flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground",
          className,
        )}
        aria-label={label}
        onClick={(e: React.MouseEvent) => e.stopPropagation()}
      >
        {icon ?? <MoreVertical className="h-4 w-4" />}
      </DropdownMenuTrigger>
      <DropdownMenuContent
        align={align}
        side={side}
        onClick={(e: React.MouseEvent) => e.stopPropagation()}
      >
        {items.map((item) => (
          <DropdownMenuItem
            key={item.key}
            disabled={item.disabled}
            onClick={item.onClick}
            className={cn(
              item.danger && "text-destructive focus:text-destructive",
            )}
          >
            {item.icon && (
              <span className="mr-2 flex items-center">{item.icon}</span>
            )}
            {item.label}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
src/registry/ui/datatable/DataTableEmptyState.tsx
"use client";

import { TableCell, TableRow } from "@/components/ui/table";

export function DataTableEmptyState({
  text,
  colSpan,
}: {
  text: string;
  colSpan: number;
}) {
  return (
    <TableRow className="hover:bg-transparent">
      <TableCell
        colSpan={colSpan}
        className="h-24 text-center text-muted-foreground"
      >
        {text}
      </TableCell>
    </TableRow>
  );
}
src/registry/ui/datatable/DataTableFooter.tsx
"use client";

import type * as React from "react";
import { TableCell, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { DataTableColumn } from "./types";

interface DataTableFooterProps<T> {
  columns: DataTableColumn<T>[];
  data: T[];
  selectable: boolean;
  expandable: boolean;
  selectionColWidth: number;
  expandColWidth: number;
  getColumnKey: (col: DataTableColumn<T>) => string;
  pinnedCellStyle: (col: DataTableColumn<T>) => React.CSSProperties;
  pinnedCellClassName: (
    col: DataTableColumn<T>,
    zIndexClass: string,
    bgClassName?: string,
  ) => string;
  sticky: boolean;
  variant: "bordered" | "borderless";
}

export function DataTableFooter<T>({
  columns,
  data,
  selectable,
  expandable,
  selectionColWidth,
  expandColWidth,
  getColumnKey,
  pinnedCellStyle,
  pinnedCellClassName,
  sticky,
  variant,
}: DataTableFooterProps<T>) {
  const hasFooter = columns.some((c) => c.footer != null);
  if (!hasFooter) return null;

  return (
    <tfoot
      className={cn(
        sticky && "sticky bottom-0 z-20",
        variant === "borderless" && "[&_tr]:border-0",
      )}
    >
      <TableRow
        className={cn(
          "bg-muted font-medium hover:bg-muted",
          variant === "borderless" && "border-0",
        )}
      >
        {selectable && (
          <TableCell
            className="sticky left-0 z-10 bg-muted"
            style={{ width: selectionColWidth }}
          />
        )}
        {expandable && (
          <TableCell
            className="sticky z-10 bg-muted"
            style={{
              left: selectable ? selectionColWidth : 0,
              width: expandColWidth,
            }}
          />
        )}
        {columns.map((col) => {
          const key = getColumnKey(col);
          return (
            <TableCell
              key={key}
              className={cn(
                pinnedCellClassName(col, "z-10", "bg-muted"),
                col.textAlign === "center" && "text-center",
                col.textAlign === "right" && "text-right",
                col.className,
              )}
              style={{ ...pinnedCellStyle(col), ...col.style }}
            >
              {typeof col.footer === "function" ? col.footer(data) : col.footer}
            </TableCell>
          );
        })}
      </TableRow>
    </tfoot>
  );
}
src/registry/ui/datatable/DataTableHeader.tsx
"use client";

import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
import * as React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { DataTableColumn, SortStatus } from "./types";

interface DataTableHeaderProps<T> {
  columns: DataTableColumn<T>[];
  selectable: boolean;
  expandable: boolean;
  selectionColWidth: number;
  expandColWidth: number;
  selectionChecked: boolean;
  selectionIndeterminate: boolean;
  onSelectionChange: () => void;
  sortStatus?: SortStatus<T>;
  onSort: (col: DataTableColumn<T>) => void;
  getColumnKey: (col: DataTableColumn<T>) => string;
  pinnedCellStyle: (col: DataTableColumn<T>) => React.CSSProperties;
  pinnedCellClassName: (
    col: DataTableColumn<T>,
    zIndexClass: string,
    bgClassName?: string,
  ) => string;
  headerClassName?: string;
  headerStyle?: React.CSSProperties;
  sticky: boolean;
  variant: "bordered" | "borderless";
}

function DataTableHeaderInner<T>(
  {
    columns,
    selectable,
    expandable,
    selectionColWidth,
    expandColWidth,
    selectionChecked,
    selectionIndeterminate,
    onSelectionChange,
    sortStatus,
    onSort,
    getColumnKey,
    pinnedCellStyle,
    pinnedCellClassName,
    headerClassName,
    headerStyle,
    sticky,
    variant,
  }: DataTableHeaderProps<T>,
  ref: React.Ref<HTMLTableSectionElement>,
) {
  function renderSortIcon(col: DataTableColumn<T>) {
    if (!col.sortable) return null;
    if (sortStatus?.columnAccessor !== col.accessor) {
      return (
        <ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground/60" />
      );
    }
    return sortStatus.direction === "asc" ? (
      <ArrowUp className="h-3.5 w-3.5" />
    ) : (
      <ArrowDown className="h-3.5 w-3.5" />
    );
  }

  return (
    <TableHeader
      ref={ref}
      className={cn(
        sticky && "sticky top-0 z-20 bg-background",
        variant === "borderless" && "[&_tr]:border-0",
      )}
    >
      <TableRow
        className={cn(variant === "borderless" && "border-0", headerClassName)}
        style={headerStyle}
      >
        {selectable && (
          <TableHead
            className="sticky left-0 z-20 bg-background"
            style={{ width: selectionColWidth }}
          >
            <Checkbox
              checked={selectionChecked}
              indeterminate={selectionIndeterminate}
              onCheckedChange={() => onSelectionChange()}
              aria-label="Select all records"
            />
          </TableHead>
        )}
        {expandable && (
          <TableHead
            className="sticky z-20 bg-background"
            style={{
              left: selectable ? selectionColWidth : 0,
              width: expandColWidth,
            }}
          />
        )}
        {columns.map((col) => {
          const key = getColumnKey(col);
          return (
            <TableHead
              key={key}
              className={cn(
                "bg-background",
                pinnedCellClassName(col, "z-20"),
                col.textAlign === "center" && "text-center",
                col.textAlign === "right" && "text-right",
                col.className,
              )}
              style={{
                width: col.width,
                ...pinnedCellStyle(col),
                ...col.style,
              }}
            >
              <div
                className={cn(
                  "flex items-center gap-1.5",
                  col.textAlign === "center" && "justify-center",
                  col.textAlign === "right" && "justify-end",
                )}
              >
                {col.sortable ? (
                  <button
                    type="button"
                    className="flex items-center gap-1.5 font-medium hover:text-foreground"
                    onClick={() => onSort(col)}
                  >
                    <span>{col.title ?? col.accessor}</span>
                    {renderSortIcon(col)}
                  </button>
                ) : (
                  <span className="font-medium">
                    {col.title ?? col.accessor}
                  </span>
                )}
              </div>
            </TableHead>
          );
        })}
      </TableRow>
    </TableHeader>
  );
}

export const DataTableHeader = React.forwardRef(DataTableHeaderInner) as <T>(
  props: DataTableHeaderProps<T> & { ref?: React.Ref<HTMLTableSectionElement> },
) => React.ReactElement;
src/registry/ui/datatable/DataTableLoader.tsx
"use client";

import { Loader2 } from "lucide-react";

export function DataTableLoader({ fetching }: { fetching?: boolean }) {
  if (!fetching) return null;
  return (
    <div className="pointer-events-none absolute inset-0 z-30 flex items-start justify-center bg-background/40 backdrop-blur-[1px]">
      <div className="mt-4 flex items-center gap-2 rounded-md border bg-background px-3 py-1.5 text-sm shadow-sm">
        <Loader2 className="h-4 w-4 animate-spin" />
        Loading...
      </div>
    </div>
  );
}
src/registry/ui/datatable/DataTablePagination.tsx
"use client";

import {
  ChevronLeft,
  ChevronRight,
  ChevronsLeft,
  ChevronsRight,
  MoreHorizontal,
} from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { DataTablePaginationProps } from "./types";
import { getPaginationRange } from "./utils";

interface Props {
  pagination: DataTablePaginationProps;
  variant: "bordered" | "borderless";
  sticky: boolean;
  footerRef?: React.Ref<HTMLDivElement>;
  fetching?: boolean;
}

export function DataTablePagination({
  pagination,
  variant,
  sticky,
  footerRef,
  fetching,
}: Props) {
  const totalPages = Math.max(
    1,
    Math.ceil(pagination.totalRecords / pagination.recordsPerPage),
  );
  const rangeStart =
    pagination.totalRecords === 0
      ? 0
      : (pagination.page - 1) * pagination.recordsPerPage + 1;
  const rangeEnd = Math.min(
    pagination.page * pagination.recordsPerPage,
    pagination.totalRecords,
  );

  const pageNumbers = React.useMemo(
    () =>
      getPaginationRange(
        pagination.page,
        totalPages,
        pagination.siblingCount ?? 1,
      ),
    [pagination.page, totalPages, pagination.siblingCount],
  );

  return (
    <div
      ref={footerRef}
      className={cn(
        "flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between",
        variant === "bordered" && "border-t",
        sticky && "shrink-0",
      )}
    >
      <div className="text-sm text-muted-foreground">
        {pagination.renderInfo
          ? pagination.renderInfo({
              from: rangeStart,
              to: rangeEnd,
              totalRecords: pagination.totalRecords,
              page: pagination.page,
              totalPages,
            })
          : pagination.totalRecords === 0
            ? "No records"
            : `Showing ${rangeStart}\u2013${rangeEnd} of ${pagination.totalRecords}`}
      </div>
      <div className="flex items-center gap-4">
        {pagination.onRecordsPerPageChange && (
          <div className="flex items-center gap-2 text-sm">
            <span className="text-muted-foreground">Rows per page</span>
            <Select
              value={String(pagination.recordsPerPage)}
              onValueChange={(v) =>
                pagination.onRecordsPerPageChange?.(Number(v))
              }
              disabled={fetching}
            >
              <SelectTrigger className="h-8 w-[70px]">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {(pagination.recordsPerPageOptions ?? [10, 25, 50, 100]).map(
                  (n) => (
                    <SelectItem key={n} value={String(n)}>
                      {n}
                    </SelectItem>
                  ),
                )}
              </SelectContent>
            </Select>
          </div>
        )}
        <div className="flex items-center gap-1">
          <Button
            variant="outline"
            size="icon"
            className="h-8 w-8"
            disabled={fetching || pagination.page <= 1}
            onClick={() => pagination.onPageChange(1)}
          >
            <ChevronsLeft className="h-4 w-4" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="h-8 w-8"
            disabled={fetching || pagination.page <= 1}
            onClick={() => pagination.onPageChange(pagination.page - 1)}
          >
            <ChevronLeft className="h-4 w-4" />
          </Button>
          {pageNumbers.map((p, i) =>
            p === "ellipsis" ? (
              <span
                // left and right ellipsis can both appear; key by the page before it
                key={`ellipsis-after-${pageNumbers[i - 1]}`}
                className="flex h-8 w-8 items-center justify-center text-muted-foreground"
              >
                <MoreHorizontal className="h-4 w-4" />
              </span>
            ) : (
              <Button
                key={p}
                disabled={fetching}
                variant={p === pagination.page ? "default" : "outline"}
                size="icon"
                className="h-8 w-8"
                onClick={() => pagination.onPageChange(p)}
              >
                {p}
              </Button>
            ),
          )}
          <Button
            variant="outline"
            size="icon"
            className="h-8 w-8"
            disabled={fetching || pagination.page >= totalPages}
            onClick={() => pagination.onPageChange(pagination.page + 1)}
          >
            <ChevronRight className="h-4 w-4" />
          </Button>
          <Button
            variant="outline"
            size="icon"
            className="h-8 w-8"
            disabled={fetching || pagination.page >= totalPages}
            onClick={() => pagination.onPageChange(totalPages)}
          >
            <ChevronsRight className="h-4 w-4" />
          </Button>
        </div>
      </div>
    </div>
  );
}
src/registry/ui/datatable/DataTableRow.tsx
"use client";

import { ChevronRight } from "lucide-react";
import * as React from "react";
import { Checkbox } from "@/components/ui/checkbox";
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { TableCell, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import type { DataTableColumn, RowContextMenuItem } from "./types";

interface DataTableRowProps<T> {
  record: T;
  rowIndex: number;
  columns: DataTableColumn<T>[];
  selectable: boolean;
  expandable: boolean;
  selectionColWidth: number;
  expandColWidth: number;
  isSelected: boolean;
  isExpanded: boolean;
  canExpand: boolean;
  renderSubContent?: (record: T, rowIndex: number) => React.ReactNode;
  onToggleSelect: (record: T, rowIndex: number, shiftKey: boolean) => void;
  onToggleExpand: (record: T) => void;
  onRowClick?: (record: T, rowIndex: number) => void;
  expandOnRowClick: boolean;
  highlightOnHover: boolean;
  rowClassName?: string;
  rowStyle?: React.CSSProperties;
  contextMenuItems?: RowContextMenuItem[];
  getColumnKey: (col: DataTableColumn<T>) => string;
  pinnedCellStyle: (col: DataTableColumn<T>) => React.CSSProperties;
  pinnedCellClassName: (
    col: DataTableColumn<T>,
    zIndexClass: string,
    bgClassName?: string,
  ) => string;
  leadingGutter: number;
  totalCols: number;
  variant: "bordered" | "borderless";
  rowRef?: (
    element: HTMLTableRowElement | null,
    record: T,
    rowIndex: number,
  ) => void;
}

function DataTableRowInner<T>({
  record,
  rowIndex,
  columns,
  selectable,
  expandable,
  selectionColWidth,
  expandColWidth,
  isSelected,
  isExpanded,
  canExpand,
  renderSubContent,
  onToggleSelect,
  onToggleExpand,
  onRowClick,
  expandOnRowClick,
  highlightOnHover,
  rowClassName,
  rowStyle,
  contextMenuItems,
  getColumnKey,
  pinnedCellStyle,
  pinnedCellClassName,
  leadingGutter,
  totalCols,
  variant,
  rowRef,
}: DataTableRowProps<T>) {
  const shiftKeyRef = React.useRef(false);

  function handleRowClick() {
    if (expandable && canExpand && expandOnRowClick) onToggleExpand(record);
    onRowClick?.(record, rowIndex);
  }

  const rowShellClassName = cn(
    highlightOnHover && "hover:bg-muted/50",
    (onRowClick || (canExpand && expandOnRowClick)) && "cursor-pointer",
    variant === "borderless" && "border-0",
    rowClassName,
  );

  const cells = (
    <React.Fragment>
      {selectable && (
        <TableCell
          className="sticky left-0 z-10 bg-background"
          style={{ width: selectionColWidth }}
          onClick={(e) => e.stopPropagation()}
        >
          <Checkbox
            checked={isSelected}
            onCheckedChange={() => {
              onToggleSelect(record, rowIndex, shiftKeyRef.current);
              // Reset so a later keyboard toggle isn't treated as shift-click.
              shiftKeyRef.current = false;
            }}
            onClick={(e: React.MouseEvent) => {
              shiftKeyRef.current = e.shiftKey;
            }}
            aria-label={`Select record ${rowIndex + 1}`}
          />
        </TableCell>
      )}
      {expandable && (
        <TableCell
          className="sticky z-10 bg-background"
          style={{
            left: selectable ? selectionColWidth : 0,
            width: expandColWidth,
          }}
          onClick={(e) => e.stopPropagation()}
        >
          {canExpand && (
            <button
              type="button"
              className="flex h-6 w-6 items-center justify-center rounded hover:bg-muted"
              onClick={() => onToggleExpand(record)}
              aria-label={isExpanded ? "Collapse row" : "Expand row"}
            >
              <ChevronRight
                className={cn(
                  "h-4 w-4 transition-transform",
                  isExpanded && "rotate-90",
                )}
              />
            </button>
          )}
        </TableCell>
      )}
      {columns.map((col) => {
        const key = getColumnKey(col);
        return (
          <TableCell
            key={key}
            className={cn(
              pinnedCellClassName(col, "z-10"),
              col.textAlign === "center" && "text-center",
              col.textAlign === "right" && "text-right",
              col.className,
            )}
            style={{ ...pinnedCellStyle(col), ...col.style }}
          >
            {col.render
              ? col.render(record, rowIndex)
              : String((record as Record<string, unknown>)[col.accessor] ?? "")}
          </TableCell>
        );
      })}
    </React.Fragment>
  );

  const mainRow =
    contextMenuItems && contextMenuItems.length > 0 ? (
      <ContextMenu>
        <ContextMenuTrigger
          render={
            <TableRow
              ref={(el: HTMLTableRowElement | null) =>
                rowRef?.(el, record, rowIndex)
              }
              data-state={isSelected ? "selected" : undefined}
              className={rowShellClassName}
              style={rowStyle}
              onClick={handleRowClick}
            />
          }
        >
          {cells}
        </ContextMenuTrigger>
        <ContextMenuContent>
          {contextMenuItems.map((item) => (
            <ContextMenuItem
              key={item.key}
              disabled={item.disabled}
              onClick={item.onClick}
              className={cn(
                item.danger && "text-destructive focus:text-destructive",
              )}
            >
              {item.icon && (
                <span className="mr-2 flex items-center">{item.icon}</span>
              )}
              {item.label}
            </ContextMenuItem>
          ))}
        </ContextMenuContent>
      </ContextMenu>
    ) : (
      <TableRow
        ref={(el) => rowRef?.(el, record, rowIndex)}
        data-state={isSelected ? "selected" : undefined}
        className={rowShellClassName}
        style={rowStyle}
        onClick={handleRowClick}
      >
        {cells}
      </TableRow>
    );

  if (!canExpand || !isExpanded) return mainRow;

  return (
    <React.Fragment>
      {mainRow}
      <TableRow
        className={cn(
          "hover:bg-transparent",
          variant === "borderless" && "border-0",
        )}
      >
        <TableCell colSpan={totalCols} className="bg-muted/30 p-0">
          <div style={{ paddingLeft: leadingGutter }} className="px-4 py-3">
            {renderSubContent?.(record, rowIndex)}
          </div>
        </TableCell>
      </TableRow>
    </React.Fragment>
  );
}

function propsAreEqual<T>(
  prev: Readonly<DataTableRowProps<T>>,
  next: Readonly<DataTableRowProps<T>>,
) {
  return (
    prev.record === next.record &&
    prev.rowIndex === next.rowIndex &&
    prev.isSelected === next.isSelected &&
    prev.isExpanded === next.isExpanded &&
    prev.canExpand === next.canExpand &&
    prev.columns === next.columns &&
    prev.rowClassName === next.rowClassName &&
    prev.rowStyle === next.rowStyle &&
    prev.contextMenuItems === next.contextMenuItems &&
    prev.pinnedCellStyle === next.pinnedCellStyle &&
    prev.pinnedCellClassName === next.pinnedCellClassName &&
    prev.getColumnKey === next.getColumnKey &&
    prev.onToggleSelect === next.onToggleSelect &&
    prev.onToggleExpand === next.onToggleExpand &&
    prev.onRowClick === next.onRowClick &&
    prev.renderSubContent === next.renderSubContent &&
    prev.selectable === next.selectable &&
    prev.expandable === next.expandable &&
    prev.expandOnRowClick === next.expandOnRowClick &&
    prev.highlightOnHover === next.highlightOnHover &&
    prev.leadingGutter === next.leadingGutter &&
    prev.totalCols === next.totalCols &&
    prev.variant === next.variant &&
    prev.rowRef === next.rowRef
  );
}

export const DataTableRow = React.memo(
  DataTableRowInner,
  propsAreEqual,
) as typeof DataTableRowInner;
src/registry/ui/datatable/index.ts
export { DataTable, default } from "./DataTable";
export type { DataTableActionProps } from "./DataTableAction";
export { DataTableAction } from "./DataTableAction";
export type {
  DataTableColumn,
  DataTablePaginationProps,
  RowContextMenuItem,
  SortDirection,
  SortStatus,
} from "./types";
export {
  createSequenceColumn,
  getColumnKey,
  getRecordId,
  getSequenceNumber,
} from "./utils";
src/registry/ui/datatable/types.ts
import type * as React from "react";

export type SortDirection = "asc" | "desc";

export interface SortStatus<T> {
  columnAccessor: Extract<keyof T, string> | (string & {});
  direction: SortDirection;
}

export interface DataTableColumn<T> {
  accessor: Extract<keyof T, string> | (string & {});
  id?: string;
  title?: React.ReactNode;
  className?: string;
  style?: React.CSSProperties;
  sortable?: boolean;
  width?: number | string;
  render?: (record: T, rowIndex: number) => React.ReactNode;
  textAlign?: "left" | "center" | "right";
  /** Pinned columns should use a numeric `width` (px); string widths fall back to 150px for sticky offsets. */
  pinned?: "left" | "right";
  footer?: React.ReactNode | ((data: T[]) => React.ReactNode);
}

export interface RowContextMenuItem {
  key: string;
  label: React.ReactNode;
  icon?: React.ReactNode;
  onClick: () => void;
  danger?: boolean;
  disabled?: boolean;
}

export interface DataTablePaginationProps {
  page: number;
  totalRecords: number;
  recordsPerPage: number;
  onPageChange: (page: number) => void;
  recordsPerPageOptions?: number[];
  onRecordsPerPageChange?: (recordsPerPage: number) => void;
  renderInfo?: (info: {
    from: number;
    to: number;
    totalRecords: number;
    page: number;
    totalPages: number;
  }) => React.ReactNode;
  siblingCount?: number;
}

export interface BaseDataTableProps<T> {
  data: T[];
  fetching?: boolean;
  columns: DataTableColumn<T>[];
  idAccessor?: keyof T | ((record: T) => React.Key);
  selectedRecords?: T[];
  onSelectedRecordsChange?: (records: T[]) => void;
  height?: number;
  rowContextMenu?: (
    record: T,
    rowIndex: number,
  ) => RowContextMenuItem[] | undefined;
  pagination?: DataTablePaginationProps;
  noRecordsText?: string;
  highlightOnHover?: boolean;
  onRowClick?: (record: T, rowIndex: number) => void;
  className?: string;
  variant?: "bordered" | "borderless";
  rowClassName?: string | ((record: T, rowIndex: number) => string | undefined);
  rowStyle?:
    | React.CSSProperties
    | ((record: T, rowIndex: number) => React.CSSProperties | undefined);
  headerClassName?: string;
  headerStyle?: React.CSSProperties;

  renderRowSubContent?: (record: T, rowIndex: number) => React.ReactNode;
  rowExpandable?: (record: T, rowIndex: number) => boolean;
  expandOnRowClick?: boolean;
  singleExpand?: boolean;
  expandedRecordIds?: React.Key[];
  onExpandedRecordIdsChange?: (ids: React.Key[]) => void;

  headerRef?: React.Ref<HTMLTableSectionElement>;
  bodyRef?: React.Ref<HTMLTableSectionElement>;
  footerRef?: React.Ref<HTMLDivElement>;
  tableRef?: React.Ref<HTMLTableElement>;
  scrollViewportRef?: React.Ref<HTMLDivElement>;
  rowRef?: (
    element: HTMLTableRowElement | null,
    record: T,
    rowIndex: number,
  ) => void;
}

export type SortProps<T> = {
  sortStatus?: SortStatus<T>;
  onSortStatusChange?: (status: SortStatus<T>) => void;
};
export type RequiredSortProps<T> = {
  sortStatus: SortStatus<T>;
  onSortStatusChange: (status: SortStatus<T>) => void;
};
src/registry/ui/datatable/utils.ts
import type * as React from "react";
import { useCallback } from "react";
import type { DataTableColumn } from "./types";

export function getRecordId<T>(
  record: T,
  idAccessor?: keyof T | ((record: T) => React.Key),
): React.Key {
  if (typeof idAccessor === "function") return idAccessor(record);
  if (idAccessor) return record[idAccessor] as unknown as React.Key;
  // Prefer a conventional `id` field; stringifying is a slow last resort
  // and treats identical records as the same row.
  const id = (record as { id?: unknown } | null)?.id;
  if (typeof id === "string" || typeof id === "number") return id;
  return JSON.stringify(record);
}

/** Stable, module-level (never recreated) — safe to pass straight into memoized children. */
export function getColumnKey<T>(col: DataTableColumn<T>): string {
  return col.id ?? col.accessor;
}

export function computeOrderedColumns<T>(
  columns: DataTableColumn<T>[],
): DataTableColumn<T>[] {
  const left = columns.filter((c) => c.pinned === "left");
  const right = columns.filter((c) => c.pinned === "right");
  const middle = columns.filter(
    (c) => c.pinned !== "left" && c.pinned !== "right",
  );
  return [...left, ...middle, ...right];
}

export function computePinOffsets<T>(
  orderedColumns: DataTableColumn<T>[],
  leadingGutter: number,
  defaultWidth: number,
) {
  const leftPinnedCols = orderedColumns.filter((c) => c.pinned === "left");
  const rightPinnedCols = orderedColumns.filter((c) => c.pinned === "right");

  const leftOffsets: Record<string, number> = {};
  let lo = leadingGutter;
  for (const col of leftPinnedCols) {
    const key = getColumnKey(col);
    leftOffsets[key] = lo;
    lo += typeof col.width === "number" ? col.width : defaultWidth;
  }

  const rightOffsets: Record<string, number> = {};
  let ro = 0;
  for (let i = rightPinnedCols.length - 1; i >= 0; i--) {
    const col = rightPinnedCols[i];
    const key = getColumnKey(col);
    rightOffsets[key] = ro;
    ro += typeof col.width === "number" ? col.width : defaultWidth;
  }

  return { leftOffsets, rightOffsets, leftPinnedCols, rightPinnedCols };
}

export function getPaginationRange(
  current: number,
  total: number,
  siblingCount = 1,
): (number | "ellipsis")[] {
  const totalVisible = siblingCount * 2 + 5;
  if (totalVisible >= total) {
    return Array.from({ length: Math.max(total, 0) }, (_, i) => i + 1);
  }
  const leftSibling = Math.max(current - siblingCount, 1);
  const rightSibling = Math.min(current + siblingCount, total);
  const showLeftEllipsis = leftSibling > 2;
  const showRightEllipsis = rightSibling < total - 1;

  const pages: (number | "ellipsis")[] = [1];
  if (showLeftEllipsis) pages.push("ellipsis");
  for (let i = leftSibling; i <= rightSibling; i++) {
    if (i !== 1 && i !== total) pages.push(i);
  }
  if (showRightEllipsis) pages.push("ellipsis");
  if (total > 1) pages.push(total);
  return pages;
}

/** Merge multiple refs (object or callback) into a single stable callback ref. */
export function useMergedRef<T>(
  ...refs: Array<React.Ref<T> | undefined>
): React.RefCallback<T> {
  return useCallback((node: T | null) => {
    for (const ref of refs) {
      if (!ref) continue;
      if (typeof ref === "function") ref(node);
      else (ref as React.MutableRefObject<T | null>).current = node;
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, refs);
}

/**
 * Row number for a "No." column, accounting for pagination — e.g. row 0 on
 * page 2 with 10 records/page is #11, not #1.
 */
export function getSequenceNumber(
  rowIndex: number,
  page?: number,
  recordsPerPage?: number,
): number {
  if (page && recordsPerPage) return (page - 1) * recordsPerPage + rowIndex + 1;
  return rowIndex + 1;
}

/**
 * Ready-made column definition for a "No." / sequence-number column.
 * Pass `page`/`recordsPerPage` when paginating so numbering continues
 * across pages instead of resetting to 1 on every page.
 *
 *   columns={[createSequenceColumn({ page, recordsPerPage }), ...yourColumns]}
 */
export function createSequenceColumn<T>(options?: {
  title?: React.ReactNode;
  width?: number | string;
  page?: number;
  recordsPerPage?: number;
  /** Override the (otherwise unused) synthetic accessor/id, e.g. if "no" collides with a real field. */
  accessor?: string;
  pinned?: "left" | "right";
}): DataTableColumn<T> {
  const {
    title = "No.",
    width = 56,
    page,
    recordsPerPage,
    accessor = "__sequence",
    pinned,
  } = options ?? {};
  return {
    accessor,
    title,
    width,
    pinned,
    textAlign: "center",
    render: (_record, rowIndex) =>
      getSequenceNumber(rowIndex, page, recordsPerPage),
  };
}

More from nekode

Browse all components in this library →