shadimage
cubby-ui
uidata-tablesource: ok · refreshed

Data Table

A data-table component.

npx shadcn add "https://www.cubby-ui.dev/r/data-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 · light
@tanstack/react-table@hugeicons/react@hugeicons/core-free-icons

Registry dependencies

@cubby-ui/checkbox@cubby-ui/toolbar@cubby-ui/table@cubby-ui/dropdown-menu@cubby-ui/button@cubby-ui/input-group@cubby-ui/elevated

Files5 · source cached 10/9/2026, 2:39:49 AM

registry/default/data-table/data-table.tsx
"use client";

import * as React from "react";
import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  flexRender,
  type ColumnDef,
  type SortingState,
  type RowSelectionState,
  type ColumnFiltersState,
  type PaginationState,
  type VisibilityState,
  type OnChangeFn,
  type Row,
  type Column,
} from "@tanstack/react-table";
import { HugeiconsIcon } from "@hugeicons/react";
import {
  ArrowUpDownIcon,
  SortByDown01Icon,
  SortByUp01Icon,
} from "@hugeicons/core-free-icons";

import { cn } from "@/lib/utils";
import { elevatedSurface } from "@/registry/default/lib/elevated";
import { Checkbox } from "@/registry/default/checkbox/checkbox";
import {
  DataTableContext,
  useDataTable,
} from "@/registry/default/data-table/data-table-context";
import {
  Toolbar,
  ToolbarSeparator,
  type ToolbarProps,
} from "@/registry/default/toolbar/toolbar";
import {
  Table,
  TableHeader,
  TableBody,
  TableFooter,
  TableHead,
  TableRow,
  TableCell,
  type TableProps,
} from "@/registry/default/table/table";
import { DataTableSearch } from "@/registry/default/data-table/data-table-search";
import { DataTableColumnToggle } from "@/registry/default/data-table/data-table-column-toggle";
import { DataTablePagination } from "@/registry/default/data-table/data-table-pagination";

export interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
  children: React.ReactNode;
  className?: string;

  enableSorting?: boolean;
  enableRowSelection?: boolean | ((row: Row<TData>) => boolean);
  enableMultiRowSelection?: boolean;
  enableFiltering?: boolean;
  enablePagination?: boolean;

  showSelectionColumn?: boolean;

  sorting?: SortingState;
  onSortingChange?: OnChangeFn<SortingState>;
  rowSelection?: RowSelectionState;
  onRowSelectionChange?: OnChangeFn<RowSelectionState>;
  columnFilters?: ColumnFiltersState;
  onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>;
  pagination?: PaginationState;
  onPaginationChange?: OnChangeFn<PaginationState>;
  columnVisibility?: VisibilityState;
  onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
  globalFilter?: string;
  onGlobalFilterChange?: OnChangeFn<string>;

  getRowId?: (originalRow: TData, index: number, parent?: Row<TData>) => string;
}

function DataTable<TData, TValue>({
  columns: userColumns,
  data,
  children,
  className,
  enableSorting = false,
  enableRowSelection = false,
  enableMultiRowSelection = true,
  enableFiltering = false,
  enablePagination = false,
  showSelectionColumn = true,
  sorting: controlledSorting,
  onSortingChange,
  rowSelection: controlledRowSelection,
  onRowSelectionChange,
  columnFilters: controlledColumnFilters,
  onColumnFiltersChange,
  pagination: controlledPagination,
  onPaginationChange,
  columnVisibility: controlledColumnVisibility,
  onColumnVisibilityChange,
  globalFilter: controlledGlobalFilter,
  onGlobalFilterChange,
  getRowId,
}: DataTableProps<TData, TValue>) {
  const [internalSorting, setInternalSorting] = React.useState<SortingState>(
    [],
  );
  const [internalRowSelection, setInternalRowSelection] =
    React.useState<RowSelectionState>({});
  const [internalColumnFilters, setInternalColumnFilters] =
    React.useState<ColumnFiltersState>([]);
  const [internalPagination, setInternalPagination] =
    React.useState<PaginationState>({
      pageIndex: 0,
      pageSize: 10,
    });
  const [internalGlobalFilter, setInternalGlobalFilter] = React.useState("");
  const [internalColumnVisibility, setInternalColumnVisibility] =
    React.useState<VisibilityState>({});

  const sorting = controlledSorting ?? internalSorting;
  const rowSelection = controlledRowSelection ?? internalRowSelection;
  const columnFilters = controlledColumnFilters ?? internalColumnFilters;
  const pagination = controlledPagination ?? internalPagination;
  const globalFilter = controlledGlobalFilter ?? internalGlobalFilter;
  const columnVisibility =
    controlledColumnVisibility ?? internalColumnVisibility;

  const columns = React.useMemo(() => {
    if (!enableRowSelection || !showSelectionColumn) {
      return userColumns;
    }

    const selectionColumn: ColumnDef<TData, unknown> = {
      id: "__select__",
      header: ({ table }) =>
        enableMultiRowSelection ? (
          <div className="flex items-center">
            <Checkbox
              checked={table.getIsAllPageRowsSelected()}
              indeterminate={
                table.getIsSomePageRowsSelected() &&
                !table.getIsAllPageRowsSelected()
              }
              onCheckedChange={(value) =>
                table.toggleAllPageRowsSelected(!!value)
              }
              aria-label="Select all"
            />
          </div>
        ) : null,
      cell: ({ row }) => (
        <div className="flex items-center">
          <Checkbox
            checked={row.getIsSelected()}
            disabled={!row.getCanSelect()}
            onCheckedChange={(value) => row.toggleSelected(!!value)}
            aria-label="Select row"
          />
        </div>
      ),
      enableSorting: false,
      enableHiding: false,
    };

    return [selectionColumn, ...userColumns];
  }, [
    userColumns,
    enableRowSelection,
    enableMultiRowSelection,
    showSelectionColumn,
  ]);

  const table = useReactTable({
    data,
    columns,
    getRowId,
    state: {
      sorting,
      rowSelection,
      columnFilters,
      pagination,
      globalFilter,
      columnVisibility,
    },
    onSortingChange: onSortingChange ?? setInternalSorting,
    onRowSelectionChange: onRowSelectionChange ?? setInternalRowSelection,
    onColumnFiltersChange: onColumnFiltersChange ?? setInternalColumnFilters,
    onPaginationChange: onPaginationChange ?? setInternalPagination,
    onGlobalFilterChange: onGlobalFilterChange ?? setInternalGlobalFilter,
    onColumnVisibilityChange:
      onColumnVisibilityChange ?? setInternalColumnVisibility,
    getCoreRowModel: getCoreRowModel(),
    ...(enableSorting && { getSortedRowModel: getSortedRowModel() }),
    ...(enableFiltering && { getFilteredRowModel: getFilteredRowModel() }),
    ...(enablePagination && { getPaginationRowModel: getPaginationRowModel() }),
    enableRowSelection,
    enableMultiRowSelection,
    globalFilterFn: "includesString",
  });

  return (
    <DataTableContext.Provider value={{ table }}>
      <div
        className={cn(
          // `relative` required: elevatedSurface's rim ::after must position inside
          // this container, not climb to the nearest positioned ancestor.
          "relative w-full rounded-2xl md:max-w-2xl",
          // pseudo-element rim keeps the border line visible above opaque sticky
          // children (DataTableToolbar, table header) in dark mode.
          elevatedSurface(3, 1),
          "bg-muted",
          className,
        )}
      >
        {children}
      </div>
    </DataTableContext.Provider>
  );
}

// A plain row above the table: its controls are fields and triggers, not
// toolbar buttons, so it skips the shell's fixed height and highlight.
function DataTableToolbar({
  className,
  variant = "ghost",
  highlight = false,
  ...props
}: ToolbarProps) {
  return (
    <Toolbar
      variant={variant}
      highlight={highlight}
      className={cn(
        "w-full items-center gap-1 rounded-b-none px-2 pt-2 pb-0 data-[orientation=horizontal]:h-auto",
        className,
      )}
      {...props}
    />
  );
}

export interface DataTableContentProps extends Omit<TableProps, "children"> {
  children: React.ReactNode;
}

function DataTableContent({
  bordered,
  striped,
  hoverable,
  rowDividers = true,
  className,
  children,
  ...tableProps
}: DataTableContentProps) {
  return (
    <Table
      bordered={bordered}
      striped={striped}
      hoverable={hoverable}
      rowDividers={rowDividers}
      className={cn(
        // Strips Table's own elevation; outer DataTable container owns it.
        // `after:hidden` removes the dark-mode rim ::after (would paint a
        // square-cornered rim over the DataTable's rounded one).
        "rounded-none bg-transparent shadow-none ring-0 after:hidden",
        className,
      )}
      {...tableProps}
    >
      {children}
    </Table>
  );
}

function SortableHeader<TData>({
  column,
  children,
  align,
  isFirst,
  isLast,
}: {
  column: Column<TData, unknown>;
  children: React.ReactNode;
  align?: "left" | "center" | "right";
  isFirst?: boolean;
  isLast?: boolean;
}) {
  return (
    <button
      type="button"
      className={cn(
        "group hover:text-foreground -mx-3 -my-2 flex w-[calc(100%+1.5rem)] cursor-pointer items-center gap-1.5 px-3 py-2 transition-colors",
        isFirst && "rounded-l-lg",
        isLast && "rounded-r-lg",
      )}
      onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
    >
      <span
        className={cn(
          "flex flex-1 items-center",
          align === "right" && "justify-end",
          align === "center" && "justify-center",
        )}
      >
        {children}
      </span>
      {column.getIsSorted() === "asc" ? (
        <HugeiconsIcon
          icon={SortByUp01Icon}
          strokeWidth={2}
          className="size-4"
        />
      ) : column.getIsSorted() === "desc" ? (
        <HugeiconsIcon
          icon={SortByDown01Icon}
          strokeWidth={2}
          className="size-4"
        />
      ) : (
        <HugeiconsIcon
          icon={ArrowUpDownIcon}
          strokeWidth={2}
          className="size-4 opacity-0 group-hover:opacity-50"
        />
      )}
    </button>
  );
}

export interface DataTableHeaderProps extends React.ComponentProps<
  typeof TableHeader
> {
  enableSorting?: boolean;
}

function DataTableHeader({
  enableSorting = false,
  className,
  ...props
}: DataTableHeaderProps) {
  const { table } = useDataTable();

  const renderHeader = (
    header: ReturnType<typeof table.getHeaderGroups>[0]["headers"][0],
    index: number,
    totalHeaders: number,
  ) => {
    if (header.isPlaceholder) return null;

    const headerDef = header.column.columnDef.header;
    const canSort = header.column.getCanSort();
    const meta = header.column.columnDef.meta as
      | { align?: "left" | "center" | "right" }
      | undefined;

    if (typeof headerDef === "string" && enableSorting && canSort) {
      return (
        <SortableHeader
          column={header.column}
          align={meta?.align}
          isFirst={index === 0}
          isLast={index === totalHeaders - 1}
        >
          {headerDef}
        </SortableHeader>
      );
    }

    return flexRender(headerDef, header.getContext());
  };

  return (
    <TableHeader className={className} {...props}>
      {table.getHeaderGroups().map((headerGroup) => (
        <TableRow key={headerGroup.id}>
          {headerGroup.headers.map((header, index) => (
            <TableHead key={header.id} colSpan={header.colSpan}>
              {renderHeader(header, index, headerGroup.headers.length)}
            </TableHead>
          ))}
        </TableRow>
      ))}
    </TableHeader>
  );
}

export interface DataTableBodyProps extends React.ComponentProps<
  typeof TableBody
> {
  emptyState?: React.ReactNode;
}

function DataTableBody({
  emptyState,
  className,
  ...props
}: DataTableBodyProps) {
  const { table } = useDataTable();
  const totalColumns = table.getAllColumns().length;

  return (
    <TableBody className={className} {...props}>
      {table.getRowModel().rows?.length ? (
        table.getRowModel().rows.map((row) => (
          <TableRow key={row.id} selected={row.getIsSelected()}>
            {row.getVisibleCells().map((cell) => (
              <TableCell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </TableCell>
            ))}
          </TableRow>
        ))
      ) : (
        <TableRow>
          <TableCell colSpan={totalColumns} className="h-24 text-center">
            {emptyState ?? "No results."}
          </TableCell>
        </TableRow>
      )}
    </TableBody>
  );
}

export type DataTableFooterProps = React.ComponentProps<typeof TableFooter>;

function DataTableFooter({
  className,
  children,
  ...props
}: DataTableFooterProps) {
  return (
    <TableFooter className={className} {...props}>
      {children}
    </TableFooter>
  );
}

export {
  DataTable,
  DataTableToolbar,
  ToolbarSeparator as DataTableToolbarSeparator,
  DataTableSearch,
  DataTableColumnToggle,
  DataTableContent,
  DataTableHeader,
  DataTableBody,
  DataTableFooter,
  DataTablePagination,
  useDataTable,
};

export type {
  ColumnDef,
  SortingState,
  RowSelectionState,
  ColumnFiltersState,
  VisibilityState,
  PaginationState,
};
registry/default/data-table/data-table-column-toggle.tsx
"use client";

import * as React from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { FilterHorizontalIcon } from "@hugeicons/core-free-icons";

import {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuCheckboxItem,
} from "@/registry/default/dropdown-menu/dropdown-menu";
import { Button } from "@/registry/default/button/button";
import { useDataTable } from "@/registry/default/data-table/data-table-context";

function DataTableColumnToggle() {
  const { table } = useDataTable();

  const toggleableColumns = table
    .getAllColumns()
    .filter((col) => col.id !== "__select__" && col.getCanHide());

  return (
    <DropdownMenu>
      <DropdownMenuTrigger render={<Button variant="ghost" size="icon_sm" />}>
        <HugeiconsIcon
          icon={FilterHorizontalIcon}
          className="size-4"
          strokeWidth={2}
        />
      </DropdownMenuTrigger>
      <DropdownMenuContent side="bottom" align="end">
        <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
        {toggleableColumns.map((column) => {
          const label =
            typeof column.columnDef.header === "string"
              ? column.columnDef.header
              : column.id;

          return (
            <DropdownMenuCheckboxItem
              key={column.id}
              checked={column.getIsVisible()}
              onCheckedChange={(checked) => column.toggleVisibility(!!checked)}
            >
              {label}
            </DropdownMenuCheckboxItem>
          );
        })}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

export { DataTableColumnToggle };
registry/default/data-table/data-table-context.tsx
"use client"

import * as React from "react"
import { type Table } from "@tanstack/react-table"

interface DataTableContextValue<TData> {
	table: Table<TData>
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Generic context requires any; type safety is enforced via useDataTable<TData>()
const DataTableContext = React.createContext<DataTableContextValue<any> | null>(
	null
)

function useDataTable<TData>() {
	const context = React.useContext(DataTableContext)
	if (!context) {
		throw new Error("useDataTable must be used within a DataTable")
	}
	return context as DataTableContextValue<TData>
}

export { DataTableContext, useDataTable }
export type { DataTableContextValue }
registry/default/data-table/data-table-pagination.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { Button } from "@/registry/default/button/button"
import { useDataTable } from "@/registry/default/data-table/data-table-context"

interface DataTablePaginationProps {
	className?: string
	showSelectedCount?: boolean
}

function DataTablePagination({
	className,
	showSelectedCount = true,
}: DataTablePaginationProps) {
	const { table } = useDataTable()

	return (
		<div
			className={cn(
				"flex items-center justify-between px-2 pb-2",
				className
			)}
		>
			{showSelectedCount ? (
				<span className="text-muted-foreground text-sm">
					{table.getFilteredSelectedRowModel().rows.length} of{" "}
					{table.getFilteredRowModel().rows.length} row(s) selected
				</span>
			) : (
				<div />
			)}
			<div className="flex items-center gap-2">
				<Button
					variant="outline"
					size="sm"
					onClick={() => table.previousPage()}
					disabled={!table.getCanPreviousPage()}
				>
					Previous
				</Button>
				<Button
					variant="outline"
					size="sm"
					onClick={() => table.nextPage()}
					disabled={!table.getCanNextPage()}
				>
					Next
				</Button>
			</div>
		</div>
	)
}

export { DataTablePagination }
registry/default/data-table/data-table-search.tsx
"use client";

import * as React from "react";
import { Toolbar } from "@base-ui/react/toolbar";
import { HugeiconsIcon } from "@hugeicons/react";
import { Search01Icon } from "@hugeicons/core-free-icons";

import { cn } from "@/lib/utils";
import { useDataTable } from "@/registry/default/data-table/data-table-context";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/registry/default/input-group/input-group";

interface DataTableSearchProps extends Omit<
  React.ComponentProps<"input">,
  "value" | "onChange" | "size"
> {
  placeholder?: string;
  size?: "default" | "sm";
}

function DataTableSearch({
  placeholder = "Search and filter",
  size = "sm",
  className,
  ...props
}: DataTableSearchProps) {
  const { table } = useDataTable();

  return (
    <InputGroup className="flex-1 border-0 bg-transparent shadow-none outline-none! dark:bg-transparent">
      <InputGroupAddon>
        <HugeiconsIcon
          icon={Search01Icon}
          className="text-muted-foreground size-4"
          strokeWidth={2}
        />
      </InputGroupAddon>
      <InputGroupInput
        placeholder={placeholder}
        size={size}
        value={table.getState().globalFilter ?? ""}
        onChange={(e) => table.setGlobalFilter(e.target.value)}
        className={cn("py-0", className)}
        render={<Toolbar.Input />}
        {...props}
      />
    </InputGroup>
  );
}

export { DataTableSearch };

More from cubby-ui

Browse all components in this library →