Data Table

An entity-agnostic, dual-mode data table built on TanStack Table. Define your columns and drop in your data — client-side out of the box, or server-side with pagination, sorting, and filtering.

npx shadcn@latest add @shuip/data-table
pnpm dlx shadcn@latest add @shuip/data-table
bun x shadcn@latest add @shuip/data-table
'use client';

import {
  closestCenter,
  DndContext,
  type DragEndEvent,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
} from '@dnd-kit/core';
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
  type Column,
  type ColumnDef,
  type ColumnFiltersState,
  type ColumnPinningState,
  flexRender,
  getCoreRowModel,
  getFacetedRowModel,
  getFacetedUniqueValues,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  type OnChangeFn,
  type PaginationState,
  type Row,
  type RowData,
  type RowSelectionState,
  type SortingState,
  type Table,
  useReactTable,
  type VisibilityState,
} from '@tanstack/react-table';
import {
  ArrowDown,
  ArrowDownUp,
  ArrowUp,
  Bookmark,
  CheckIcon,
  ChevronDown,
  ChevronLeft,
  ChevronRight,
  ChevronsLeft,
  ChevronsRight,
  ChevronsUpDown,
  EyeOff,
  GripVertical,
  Inbox,
  ListFilter,
  Loader2,
  Plus,
  PlusCircle,
  Settings2,
  Trash2,
  X,
} from 'lucide-react';
import * as React from 'react';

import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from '@/components/ui/command';
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { TableBody, TableCell, TableHead, TableHeader, Table as TableRoot, TableRow } from '@/components/ui/table';
import { cn } from '@/lib/utils';

export type DataTableFilterOption = {
  label: string;
  value: string;
  icon?: React.ComponentType<{ className?: string }>;
  count?: number;
};

declare module '@tanstack/react-table' {
  interface ColumnMeta<TData extends RowData, TValue> {
    label?: string;
    placeholder?: string;
    variant?: 'text' | 'number' | 'date' | 'select' | 'multiSelect';
    options?: DataTableFilterOption[];
    icon?: React.ComponentType<{ className?: string }>;
  }
}

export type FilterVariant = NonNullable<DataTableColumnMetaVariant>;
type DataTableColumnMetaVariant = 'text' | 'number' | 'date' | 'select' | 'multiSelect';

export type FilterOperator =
  | 'contains'
  | 'notContains'
  | 'is'
  | 'isNot'
  | 'isEmpty'
  | 'isNotEmpty'
  | 'eq'
  | 'ne'
  | 'gt'
  | 'lt'
  | 'gte'
  | 'lte'
  | 'before'
  | 'after'
  | 'onOrBefore'
  | 'onOrAfter'
  | 'isAnyOf'
  | 'isNoneOf';

export type FilterCondition = { operator: FilterOperator; value: unknown };

const OPERATOR_LABELS: Record<FilterOperator, string> = {
  contains: 'contains',
  notContains: 'does not contain',
  is: 'is',
  isNot: 'is not',
  isEmpty: 'is empty',
  isNotEmpty: 'is not empty',
  eq: '=',
  ne: '≠',
  gt: '>',
  lt: '<',
  gte: '≥',
  lte: '≤',
  before: 'is before',
  after: 'is after',
  onOrBefore: 'is on or before',
  onOrAfter: 'is on or after',
  isAnyOf: 'is any of',
  isNoneOf: 'is none of',
};

const OPERATORS_BY_VARIANT: Record<FilterVariant, FilterOperator[]> = {
  text: ['contains', 'notContains', 'is', 'isNot', 'isEmpty', 'isNotEmpty'],
  number: ['eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'isEmpty', 'isNotEmpty'],
  date: ['is', 'before', 'after', 'onOrBefore', 'onOrAfter', 'isEmpty'],
  select: ['is', 'isNot', 'isAnyOf', 'isNoneOf'],
  multiSelect: ['isAnyOf', 'isNoneOf', 'is', 'isNot'],
};

const MULTI_VALUE_OPERATORS: FilterOperator[] = ['isAnyOf', 'isNoneOf'];

function operatorTakesValue(operator: FilterOperator): boolean {
  return operator !== 'isEmpty' && operator !== 'isNotEmpty';
}

function isFilterCondition(value: unknown): value is FilterCondition {
  return typeof value === 'object' && value !== null && 'operator' in value;
}

function conditionIsEmpty(condition: FilterCondition): boolean {
  if (!operatorTakesValue(condition.operator)) return false;
  const { value } = condition;
  return value == null || value === '' || (Array.isArray(value) && value.length === 0);
}

export const dataTableFilterFn = <TData,>(row: Row<TData>, columnId: string, filterValue: unknown): boolean => {
  if (Array.isArray(filterValue)) {
    const cell = row.getValue(columnId);
    return filterValue.length === 0 || filterValue.includes(cell);
  }
  if (!isFilterCondition(filterValue)) return true;
  const { operator, value } = filterValue;
  if (conditionIsEmpty(filterValue)) return true;

  const cell = row.getValue(columnId);
  const text = String(cell ?? '').toLowerCase();
  const target = String(value ?? '').toLowerCase();

  switch (operator) {
    case 'isEmpty':
      return cell == null || cell === '';
    case 'isNotEmpty':
      return !(cell == null || cell === '');
    case 'contains':
      return text.includes(target);
    case 'notContains':
      return !text.includes(target);
    case 'is':
      return text === target;
    case 'isNot':
      return text !== target;
    case 'eq':
      return Number(cell) === Number(value);
    case 'ne':
      return Number(cell) !== Number(value);
    case 'gt':
      return Number(cell) > Number(value);
    case 'lt':
      return Number(cell) < Number(value);
    case 'gte':
      return Number(cell) >= Number(value);
    case 'lte':
      return Number(cell) <= Number(value);
    case 'before':
      return new Date(String(cell)) < new Date(String(value));
    case 'after':
      return new Date(String(cell)) > new Date(String(value));
    case 'onOrBefore':
      return new Date(String(cell)) <= new Date(String(value));
    case 'onOrAfter':
      return new Date(String(cell)) >= new Date(String(value));
    case 'isAnyOf':
      return Array.isArray(value) ? value.includes(cell) : true;
    case 'isNoneOf':
      return Array.isArray(value) ? !value.includes(cell) : true;
    default:
      return true;
  }
};

export type UseDataTableProps<TData> = {
  data: TData[];
  columns: ColumnDef<TData, unknown>[];
  pageCount?: number;
  getRowId?: (row: TData, index: number) => string;
  enableRowSelection?: boolean;
  enableColumnPinning?: boolean;
  initialState?: {
    pagination?: PaginationState;
    sorting?: SortingState;
    columnVisibility?: VisibilityState;
    columnPinning?: ColumnPinningState;
  };
  state?: {
    pagination?: PaginationState;
    sorting?: SortingState;
    columnFilters?: ColumnFiltersState;
    globalFilter?: string;
  };
  onPaginationChange?: OnChangeFn<PaginationState>;
  onSortingChange?: OnChangeFn<SortingState>;
  onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>;
  onGlobalFilterChange?: OnChangeFn<string>;
};

export function useDataTable<TData>(props: UseDataTableProps<TData>) {
  const {
    data,
    columns,
    pageCount,
    getRowId,
    enableRowSelection = false,
    enableColumnPinning = false,
    initialState,
  } = props;

  const manual = pageCount != null;

  const [sorting, setSorting] = React.useState<SortingState>(initialState?.sorting ?? []);
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
  const [globalFilter, setGlobalFilter] = React.useState('');
  const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(initialState?.columnVisibility ?? {});
  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
  const [pagination, setPagination] = React.useState<PaginationState>(
    initialState?.pagination ?? { pageIndex: 0, pageSize: 10 },
  );
  const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>(() => ({
    left: initialState?.columnPinning?.left ?? [],
    right: initialState?.columnPinning?.right ?? [],
  }));

  const currentSorting = props.state?.sorting ?? sorting;
  const currentColumnFilters = props.state?.columnFilters ?? columnFilters;
  const currentGlobalFilter = props.state?.globalFilter ?? globalFilter;

  const table = useReactTable<TData>({
    data,
    columns,
    pageCount: pageCount ?? undefined,
    defaultColumn: { filterFn: dataTableFilterFn },
    autoResetPageIndex: false,
    state: {
      sorting: currentSorting,
      columnFilters: currentColumnFilters,
      globalFilter: currentGlobalFilter,
      columnVisibility,
      rowSelection,
      pagination: props.state?.pagination ?? pagination,
      columnPinning,
    },
    enableRowSelection,
    enableColumnPinning,
    getRowId,
    manualPagination: manual,
    manualSorting: manual,
    manualFiltering: manual,
    onSortingChange: props.onSortingChange ?? setSorting,
    onColumnFiltersChange: props.onColumnFiltersChange ?? setColumnFilters,
    onGlobalFilterChange: props.onGlobalFilterChange ?? setGlobalFilter,
    onColumnVisibilityChange: setColumnVisibility,
    onRowSelectionChange: setRowSelection,
    onPaginationChange: props.onPaginationChange ?? setPagination,
    onColumnPinningChange: setColumnPinning,
    getCoreRowModel: getCoreRowModel(),
    ...(manual
      ? {}
      : {
          getSortedRowModel: getSortedRowModel(),
          getFilteredRowModel: getFilteredRowModel(),
          getPaginationRowModel: getPaginationRowModel(),
          getFacetedRowModel: getFacetedRowModel(),
          getFacetedUniqueValues: getFacetedUniqueValues(),
        }),
  });

  // TanStack queues the reset of the page from the render that computes the rows, and a React that renders in slices,
  // as it does when it hydrates, runs that microtask before the component has mounted. The reset runs here instead,
  // from a layout effect so that no frame shows a page the new rows do not reach, and only when the rows can have
  // changed. The inputs are compared through a ref, not a dependency array: an array would also run the effect on
  // mount and on the second run of strict mode, and send a table that starts on `initialState.pagination` back to
  // its first page.
  const rowInputs = [data, currentSorting, currentColumnFilters, currentGlobalFilter];
  const previousRowInputs = React.useRef(rowInputs);
  React.useLayoutEffect(() => {
    const previous = previousRowInputs.current;
    previousRowInputs.current = rowInputs;
    if (manual) return;
    if (rowInputs.every((input, index) => Object.is(input, previous[index]))) return;
    table.resetPageIndex();
  });

  return { table };
}

function getColumnStyles<TData>(column: Column<TData>): React.CSSProperties {
  const pinned = column.getIsPinned();
  return {
    width: column.getSize(),
    ...(pinned
      ? {
          position: 'sticky',
          left: pinned === 'left' ? column.getStart('left') : undefined,
          right: pinned === 'right' ? column.getAfter('right') : undefined,
          zIndex: 1,
        }
      : {}),
  };
}

function getPinClass<TData>(column: Column<TData>): string | undefined {
  return column.getIsPinned() ? 'bg-background' : undefined;
}

const skeletonBar = <div className='h-5 w-full animate-pulse rounded bg-muted' />;

const shimmerBar = (
  <div className='relative h-5 w-full overflow-hidden rounded bg-muted'>
    <div
      className='absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-foreground/10 to-transparent motion-reduce:hidden'
      style={{ animation: 'shuip-dt-shimmer 1.5s infinite' }}
    />
  </div>
);

const shimmerKeyframes = <style>{'@keyframes shuip-dt-shimmer{100%{transform:translateX(100%)}}'}</style>;

export type DataTableProps<TData> = {
  table: Table<TData>;
  isLoading?: boolean;
  loadingVariant?: 'skeleton' | 'overlay' | 'shimmer';
  emptyState?: React.ReactNode;
  onRowClick?: (row: TData) => void;
  className?: string;
};

export function DataTable<TData>({
  table,
  isLoading,
  loadingVariant = 'skeleton',
  emptyState,
  onRowClick,
  className,
}: DataTableProps<TData>) {
  const columnCount = table.getVisibleLeafColumns().length;
  const rows = table.getRowModel().rows;
  const showSkeleton = isLoading && loadingVariant !== 'overlay';
  const showOverlay = isLoading && loadingVariant === 'overlay';

  return (
    <div className={cn('relative rounded-md border', className)}>
      <TableRoot
        className={cn('table-fixed', showOverlay && 'pointer-events-none opacity-60')}
        style={{ minWidth: table.getTotalSize() }}
      >
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <TableHead
                  key={header.id}
                  colSpan={header.colSpan}
                  style={getColumnStyles(header.column)}
                  className={getPinClass(header.column)}
                >
                  {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {showSkeleton ? (
            Array.from({ length: table.getState().pagination.pageSize }).map((_, rowIndex) => (
              <TableRow key={rowIndex}>
                {table.getVisibleLeafColumns().map((column) => (
                  <TableCell key={column.id} style={getColumnStyles(column)} className={getPinClass(column)}>
                    {loadingVariant === 'shimmer' ? shimmerBar : skeletonBar}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : rows.length ? (
            rows.map((row) => (
              <TableRow
                key={row.id}
                data-state={row.getIsSelected() ? 'selected' : undefined}
                onClick={onRowClick ? () => onRowClick(row.original) : undefined}
                className={onRowClick ? 'cursor-pointer' : undefined}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell
                    key={cell.id}
                    style={getColumnStyles(cell.column)}
                    className={cn('overflow-hidden', getPinClass(cell.column))}
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columnCount} className='h-24 text-center'>
                {emptyState ?? 'No results.'}
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </TableRoot>
      {showOverlay && (
        <div className='absolute inset-0 z-10 flex items-center justify-center bg-background/60 backdrop-blur-[1px]'>
          <Loader2 className='size-5 animate-spin text-muted-foreground' />
        </div>
      )}
      {loadingVariant === 'shimmer' && shimmerKeyframes}
    </div>
  );
}

export type DataTableEmptyProps = {
  variant?: 'text' | 'illustrated' | 'with-action';
  title?: string;
  description?: string;
  icon?: React.ComponentType<{ className?: string }>;
  action?: React.ReactNode;
};

export function DataTableEmpty({
  variant = 'text',
  title = 'No results',
  description,
  icon: Icon = Inbox,
  action,
}: DataTableEmptyProps) {
  if (variant === 'text') {
    return <span className='text-muted-foreground text-sm'>{title}</span>;
  }

  return (
    <div className='flex flex-col items-center justify-center gap-3 py-6 text-center'>
      <div className='flex size-12 items-center justify-center rounded-full bg-muted/50'>
        <Icon className='size-6 text-muted-foreground' />
      </div>
      <div className='space-y-1'>
        <p className='text-balance font-medium text-sm'>{title}</p>
        {description && <p className='mx-auto max-w-xs text-balance text-muted-foreground text-sm'>{description}</p>}
      </div>
      {variant === 'with-action' && action}
    </div>
  );
}

export type DataTableColumnHeaderProps<TData, TValue> = {
  column: Column<TData, TValue>;
  title: string;
  className?: string;
};

export function DataTableColumnHeader<TData, TValue>({
  column,
  title,
  className,
}: DataTableColumnHeaderProps<TData, TValue>) {
  if (!column.getCanSort() && !column.getCanHide()) {
    return <div className={className}>{title}</div>;
  }

  const sorted = column.getIsSorted();

  return (
    <Popover>
      <PopoverTrigger render={<Button variant='ghost' size='sm' className={cn('-ml-3 h-8', className)} />}>
        <span>{title}</span>
        {sorted === 'desc' ? (
          <ArrowDown className='ml-2 size-4' />
        ) : sorted === 'asc' ? (
          <ArrowUp className='ml-2 size-4' />
        ) : (
          <ChevronsUpDown className='ml-2 size-4' />
        )}
        {sorted && column.getSortIndex() >= 1 && (
          <span className='ml-1 rounded bg-muted px-1 font-mono text-[10px] text-muted-foreground tabular-nums'>
            {column.getSortIndex() + 1}
          </span>
        )}
      </PopoverTrigger>
      <PopoverContent align='start' className='flex w-40 flex-col gap-0.5 p-1'>
        {column.getCanSort() && (
          <>
            <Button variant='ghost' size='sm' className='justify-start' onClick={() => column.toggleSorting(false)}>
              <ArrowUp className='mr-2 size-3.5 text-muted-foreground/70' /> Asc
            </Button>
            <Button variant='ghost' size='sm' className='justify-start' onClick={() => column.toggleSorting(true)}>
              <ArrowDown className='mr-2 size-3.5 text-muted-foreground/70' /> Desc
            </Button>
          </>
        )}
        {column.getCanHide() && (
          <Button variant='ghost' size='sm' className='justify-start' onClick={() => column.toggleVisibility(false)}>
            <EyeOff className='mr-2 size-3.5 text-muted-foreground/70' /> Hide
          </Button>
        )}
      </PopoverContent>
    </Popover>
  );
}

export type DataTableFacetedFilterProps<TData, TValue> = {
  column?: Column<TData, TValue>;
  title?: string;
  options: DataTableFilterOption[];
};

export function DataTableFacetedFilter<TData, TValue>({
  column,
  title,
  options,
}: DataTableFacetedFilterProps<TData, TValue>) {
  const facets = column?.getFacetedUniqueValues();
  const selectedValues = new Set((column?.getFilterValue() as string[]) ?? []);

  return (
    <Popover>
      <PopoverTrigger render={<Button variant='outline' size='sm' className='h-8 border-dashed' />}>
        <PlusCircle className='mr-2 size-4' />
        {title}
        {selectedValues.size > 0 && (
          <>
            <Separator orientation='vertical' className='mx-2 h-4' />
            <span className='rounded-sm bg-secondary px-1 font-normal text-xs'>{selectedValues.size}</span>
          </>
        )}
      </PopoverTrigger>
      <PopoverContent className='w-48 p-0' align='start'>
        <Command>
          <CommandInput placeholder={title} />
          <CommandList>
            <CommandEmpty>No results.</CommandEmpty>
            <CommandGroup>
              {options.map((option) => {
                const isSelected = selectedValues.has(option.value);
                return (
                  <CommandItem
                    key={option.value}
                    onSelect={() => {
                      const next = new Set(selectedValues);
                      if (isSelected) next.delete(option.value);
                      else next.add(option.value);
                      const filterValues = Array.from(next);
                      column?.setFilterValue(filterValues.length ? filterValues : undefined);
                    }}
                  >
                    <div
                      className={cn(
                        'mr-2 flex size-4 items-center justify-center rounded-sm border border-primary',
                        isSelected ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible',
                      )}
                    >
                      <CheckIcon className='size-3.5' />
                    </div>
                    {option.icon && <option.icon className='mr-2 size-4 text-muted-foreground' />}
                    <span>{option.label}</span>
                    {(facets?.get(option.value) ?? 0) > 0 && (
                      <span className='ml-auto flex size-4 items-center justify-center font-mono text-xs'>
                        {facets?.get(option.value)}
                      </span>
                    )}
                  </CommandItem>
                );
              })}
            </CommandGroup>
            {selectedValues.size > 0 && (
              <>
                <CommandSeparator />
                <CommandGroup>
                  <CommandItem
                    onSelect={() => column?.setFilterValue(undefined)}
                    className='justify-center text-center'
                  >
                    Clear filters
                  </CommandItem>
                </CommandGroup>
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

export function DataTableViewOptions<TData>({ table }: { table: Table<TData> }) {
  const columns = table
    .getAllColumns()
    .filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide());

  return (
    <Popover>
      <PopoverTrigger render={<Button variant='outline' size='sm' className='ml-auto hidden h-8 lg:flex' />}>
        <Settings2 className='mr-2 size-4' /> View
      </PopoverTrigger>
      <PopoverContent align='end' className='w-44 p-0'>
        <Command>
          <CommandInput placeholder='Search columns...' />
          <CommandList>
            <CommandEmpty>No columns.</CommandEmpty>
            <CommandGroup>
              {columns.map((column) => (
                <CommandItem key={column.id} onSelect={() => column.toggleVisibility(!column.getIsVisible())}>
                  <CheckIcon className={cn('mr-2 size-4', column.getIsVisible() ? 'opacity-100' : 'opacity-0')} />
                  <span className='truncate'>{column.columnDef.meta?.label ?? column.id}</span>
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

type FilterChip = { key: string; label: string; onRemove: () => void };

function getFilterChips<TData>(table: Table<TData>): FilterChip[] {
  const chips: FilterChip[] = [];
  const globalFilter = table.getState().globalFilter as string;
  if (globalFilter) {
    chips.push({ key: 'global', label: `Search: ${globalFilter}`, onRemove: () => table.setGlobalFilter('') });
  }
  for (const filter of table.getState().columnFilters) {
    const column = table.getColumn(filter.id);
    const meta = column?.columnDef.meta;
    const columnLabel = meta?.label ?? filter.id;
    const values = Array.isArray(filter.value) ? (filter.value as string[]) : [filter.value as string];
    for (const value of values) {
      const optionLabel = meta?.options?.find((option) => option.value === value)?.label ?? String(value);
      chips.push({
        key: `${filter.id}-${value}`,
        label: `${columnLabel}: ${optionLabel}`,
        onRemove: () => {
          const next = values.filter((item) => item !== value);
          column?.setFilterValue(next.length ? next : undefined);
        },
      });
    }
  }
  return chips;
}

export type DataTableToolbarProps<TData> = {
  table: Table<TData>;
  searchPlaceholder?: string;
  variant?: 'default' | 'inline-chips' | 'minimal';
  children?: React.ReactNode;
};

export function DataTableToolbar<TData>({
  table,
  searchPlaceholder = 'Search...',
  variant = 'default',
  children,
}: DataTableToolbarProps<TData>) {
  const isFiltered = table.getState().columnFilters.length > 0 || Boolean(table.getState().globalFilter);
  const showFacets = variant !== 'minimal';
  const showChips = variant === 'inline-chips';
  const filterableColumns = table
    .getAllColumns()
    .filter((column) => column.getCanFilter() && column.columnDef.meta?.variant);
  const chips = showChips ? getFilterChips(table) : [];

  const clearAll = () => {
    table.resetColumnFilters();
    table.setGlobalFilter('');
  };

  return (
    <div className='flex flex-col gap-2'>
      <div className='flex flex-wrap items-center gap-2'>
        <Input
          placeholder={searchPlaceholder}
          value={(table.getState().globalFilter as string) ?? ''}
          onChange={(event) => table.setGlobalFilter(event.target.value)}
          className='h-8 w-40 lg:w-56'
        />
        {showFacets &&
          filterableColumns.map((column) => {
            const meta = column.columnDef.meta;
            if ((meta?.variant === 'select' || meta?.variant === 'multiSelect') && meta.options) {
              return (
                <DataTableFacetedFilter
                  key={column.id}
                  column={column}
                  title={meta.label ?? column.id}
                  options={meta.options}
                />
              );
            }
            return null;
          })}
        {isFiltered && !showChips && (
          <Button variant='ghost' size='sm' className='h-8 px-2' onClick={clearAll}>
            Reset <X className='ml-2 size-4' />
          </Button>
        )}
        {children}
        <DataTableViewOptions table={table} />
      </div>
      {showChips && chips.length > 0 && (
        <div className='flex flex-wrap items-center gap-1.5'>
          {chips.map((chip) => (
            <span
              key={chip.key}
              className='inline-flex items-center gap-1 rounded-full bg-secondary py-0.5 pr-1 pl-2 text-secondary-foreground text-xs'
            >
              {chip.label}
              <button
                type='button'
                onClick={chip.onRemove}
                aria-label={`Remove ${chip.label}`}
                className='flex size-4 items-center justify-center rounded-full hover:bg-background/60'
              >
                <X className='size-3' />
              </button>
            </span>
          ))}
          <Button variant='ghost' size='sm' className='h-6 px-2 text-xs' onClick={clearAll}>
            Clear all
          </Button>
        </div>
      )}
    </div>
  );
}

function getPaginationRange(currentPage: number, pageCount: number, siblings = 1): (number | 'ellipsis')[] {
  const totalPageNumbers = siblings * 2 + 5;
  if (pageCount <= totalPageNumbers) {
    return Array.from({ length: pageCount }, (_, index) => index + 1);
  }

  const leftSibling = Math.max(currentPage - siblings, 1);
  const rightSibling = Math.min(currentPage + siblings, pageCount);
  const showLeftEllipsis = leftSibling > 2;
  const showRightEllipsis = rightSibling < pageCount - 1;
  const edgeCount = 3 + 2 * siblings;

  if (!showLeftEllipsis && showRightEllipsis) {
    return [...Array.from({ length: edgeCount }, (_, index) => index + 1), 'ellipsis', pageCount];
  }
  if (showLeftEllipsis && !showRightEllipsis) {
    return [1, 'ellipsis', ...Array.from({ length: edgeCount }, (_, index) => pageCount - edgeCount + 1 + index)];
  }
  return [
    1,
    'ellipsis',
    ...Array.from({ length: rightSibling - leftSibling + 1 }, (_, index) => leftSibling + index),
    'ellipsis',
    pageCount,
  ];
}

export type DataTablePaginationProps<TData> = {
  table: Table<TData>;
  pageSizeOptions?: number[];
  variant?: 'simple' | 'numbered';
};

export function DataTablePagination<TData>({
  table,
  pageSizeOptions = [10, 20, 30, 40, 50],
  variant = 'simple',
}: DataTablePaginationProps<TData>) {
  const pageSize = table.getState().pagination.pageSize;
  const pageIndex = table.getState().pagination.pageIndex;
  const pageCount = Math.max(table.getPageCount(), 1);
  const options = pageSizeOptions.includes(pageSize)
    ? pageSizeOptions
    : [...pageSizeOptions, pageSize].sort((a, b) => a - b);

  return (
    <div className='flex flex-wrap items-center justify-between gap-4'>
      <div className='text-muted-foreground text-sm'>
        {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
      </div>
      <div className='flex flex-wrap items-center gap-4 lg:gap-6'>
        <div className='flex items-center gap-2'>
          <p className='font-medium text-sm'>Rows per page</p>
          <Select
            value={`${pageSize}`}
            onValueChange={(value: string | null) => value !== null && table.setPageSize(Number(value))}
          >
            <SelectTrigger className='h-8 w-[72px]'>
              <SelectValue placeholder={pageSize} />
            </SelectTrigger>
            <SelectContent side='top'>
              {options.map((size) => (
                <SelectItem key={size} value={`${size}`}>
                  {size}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
        {variant === 'numbered' ? (
          <div className='flex items-center gap-1'>
            <Button
              variant='outline'
              size='icon-sm'
              onClick={() => table.previousPage()}
              disabled={!table.getCanPreviousPage()}
              aria-label='Previous page'
            >
              <ChevronLeft className='size-4' />
            </Button>
            {getPaginationRange(pageIndex + 1, pageCount).map((page, index) =>
              page === 'ellipsis' ? (
                <span
                  key={`ellipsis-${index}`}
                  className='flex size-8 items-center justify-center text-muted-foreground text-sm'
                >
                  &hellip;
                </span>
              ) : (
                <Button
                  key={page}
                  variant={page === pageIndex + 1 ? 'default' : 'ghost'}
                  size='icon-sm'
                  className='tabular-nums'
                  aria-current={page === pageIndex + 1 ? 'page' : undefined}
                  onClick={() => table.setPageIndex(page - 1)}
                >
                  {page}
                </Button>
              ),
            )}
            <Button
              variant='outline'
              size='icon-sm'
              onClick={() => table.nextPage()}
              disabled={!table.getCanNextPage()}
              aria-label='Next page'
            >
              <ChevronRight className='size-4' />
            </Button>
          </div>
        ) : (
          <>
            <div className='flex items-center font-medium text-sm'>
              Page {pageIndex + 1} of {pageCount}
            </div>
            <div className='flex items-center gap-2'>
              <Button
                variant='outline'
                size='icon-sm'
                className='hidden lg:flex'
                onClick={() => table.setPageIndex(0)}
                disabled={!table.getCanPreviousPage()}
                aria-label='First page'
              >
                <ChevronsLeft className='size-4' />
              </Button>
              <Button
                variant='outline'
                size='icon-sm'
                onClick={() => table.previousPage()}
                disabled={!table.getCanPreviousPage()}
                aria-label='Previous page'
              >
                <ChevronLeft className='size-4' />
              </Button>
              <Button
                variant='outline'
                size='icon-sm'
                onClick={() => table.nextPage()}
                disabled={!table.getCanNextPage()}
                aria-label='Next page'
              >
                <ChevronRight className='size-4' />
              </Button>
              <Button
                variant='outline'
                size='icon-sm'
                className='hidden lg:flex'
                onClick={() => table.setPageIndex(table.getPageCount() - 1)}
                disabled={!table.getCanNextPage()}
                aria-label='Last page'
              >
                <ChevronsRight className='size-4' />
              </Button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

export type DataTableLoadMoreProps = {
  onLoadMore: () => void;
  hasMore: boolean;
  mode?: 'button' | 'infinite';
  isLoading?: boolean;
  loaded?: number;
  total?: number;
};

export function DataTableLoadMore({
  onLoadMore,
  hasMore,
  mode = 'button',
  isLoading = false,
  loaded,
  total,
}: DataTableLoadMoreProps) {
  const sentinelRef = React.useRef<HTMLDivElement>(null);
  const onLoadMoreRef = React.useRef(onLoadMore);

  // Resynchronised on commit, never during render: a discarded render must not
  // leave its callback where the observer can call it.
  React.useEffect(() => {
    onLoadMoreRef.current = onLoadMore;
  });

  React.useEffect(() => {
    if (mode !== 'infinite' || !hasMore || isLoading) return;
    const node = sentinelRef.current;
    if (!node) return;
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting) onLoadMoreRef.current();
      },
      { rootMargin: '120px' },
    );
    observer.observe(node);
    return () => observer.disconnect();
  }, [mode, hasMore, isLoading]);

  const count = loaded != null && total != null ? `Showing ${loaded} of ${total}` : null;

  if (mode === 'infinite') {
    return (
      <div className='flex min-h-9 flex-col items-center justify-center gap-2 py-4 text-muted-foreground text-sm'>
        {hasMore ? (
          <>
            <div ref={sentinelRef} aria-hidden className='h-px w-full' />
            {isLoading && <Loader2 className='size-4 animate-spin' />}
          </>
        ) : (
          <span>All caught up</span>
        )}
      </div>
    );
  }

  return (
    <div className='flex flex-col items-center gap-2 py-4'>
      {hasMore ? (
        <Button variant='outline' size='sm' onClick={onLoadMore} disabled={isLoading}>
          {isLoading && <Loader2 className='mr-2 size-4 animate-spin' />}
          Load more
        </Button>
      ) : (
        <span className='text-muted-foreground text-sm'>All caught up</span>
      )}
      {count && <span className='text-muted-foreground text-xs'>{count}</span>}
    </div>
  );
}

type FilterField = { id: string; label: string; variant: FilterVariant; options?: DataTableFilterOption[] };
type SortField = { id: string; label: string };

function getFilterableFields<TData>(table: Table<TData>): FilterField[] {
  return table
    .getAllColumns()
    .filter((column) => column.getCanFilter() && column.columnDef.meta?.variant)
    .map((column) => ({
      id: column.id,
      label: column.columnDef.meta?.label ?? column.id,
      variant: column.columnDef.meta?.variant as FilterVariant,
      options: column.columnDef.meta?.options,
    }));
}

function getSortableFields<TData>(table: Table<TData>): SortField[] {
  return table
    .getAllColumns()
    .filter((column) => column.getCanSort())
    .map((column) => ({ id: column.id, label: column.columnDef.meta?.label ?? column.id }));
}

function defaultOperatorFor(variant: FilterVariant): FilterOperator {
  return OPERATORS_BY_VARIANT[variant][0];
}

function defaultValueFor(variant: FilterVariant): unknown {
  return MULTI_VALUE_OPERATORS.includes(defaultOperatorFor(variant)) ? [] : '';
}

function useReorderSensors() {
  return useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  );
}

function SortableRow({ id, children }: { id: string; children: React.ReactNode }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
  const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition };
  return (
    <div ref={setNodeRef} style={style} className={cn('flex items-center gap-2', isDragging && 'opacity-60')}>
      <button
        type='button'
        className='shrink-0 cursor-grab touch-none text-muted-foreground/50 active:cursor-grabbing'
        aria-label='Reorder'
        {...attributes}
        {...listeners}
      >
        <GripVertical className='size-4' />
      </button>
      {children}
    </div>
  );
}

function FieldSelect({
  fields,
  value,
  onChange,
}: {
  fields: { id: string; label: string }[];
  value: string;
  onChange: (value: string) => void;
}) {
  return (
    <Select value={value} onValueChange={(next: string | null) => next !== null && onChange(next)}>
      <SelectTrigger className='h-8 w-[120px] shrink-0'>
        <SelectValue>{fields.find((field) => field.id === value)?.label ?? value}</SelectValue>
      </SelectTrigger>
      <SelectContent>
        {fields.map((field) => (
          <SelectItem key={field.id} value={field.id}>
            {field.label}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

function OperatorSelect({
  variant,
  value,
  onChange,
}: {
  variant: FilterVariant;
  value: FilterOperator;
  onChange: (value: FilterOperator) => void;
}) {
  return (
    <Select value={value} onValueChange={(next: string | null) => next !== null && onChange(next as FilterOperator)}>
      <SelectTrigger className='h-8 w-[136px] shrink-0'>
        <SelectValue>{OPERATOR_LABELS[value] ?? value}</SelectValue>
      </SelectTrigger>
      <SelectContent>
        {OPERATORS_BY_VARIANT[variant].map((operator) => (
          <SelectItem key={operator} value={operator}>
            {OPERATOR_LABELS[operator]}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

function MultiSelectValue({
  options,
  selected,
  onChange,
}: {
  options: DataTableFilterOption[];
  selected: string[];
  onChange: (value: string[]) => void;
}) {
  const toggle = (value: string) =>
    onChange(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value]);
  const label =
    selected.length === 0
      ? 'Select…'
      : selected.length === 1
        ? (options.find((option) => option.value === selected[0])?.label ?? selected[0])
        : `${selected.length} selected`;

  return (
    <Popover>
      <PopoverTrigger
        render={<Button variant='outline' size='sm' className='h-8 flex-1 justify-between font-normal' />}
      >
        <span className='truncate'>{label}</span>
        <ChevronDown className='ml-1 size-3.5 shrink-0 text-muted-foreground' />
      </PopoverTrigger>
      <PopoverContent align='start' className='w-48 p-1'>
        <div className='flex flex-col gap-0.5'>
          {options.map((option) => (
            <button
              key={option.value}
              type='button'
              onClick={() => toggle(option.value)}
              className='flex items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent'
            >
              <Checkbox checked={selected.includes(option.value)} className='pointer-events-none' />
              <span>{option.label}</span>
            </button>
          ))}
        </div>
      </PopoverContent>
    </Popover>
  );
}

function FilterValueControl({
  field,
  condition,
  onChange,
}: {
  field: FilterField;
  condition: FilterCondition;
  onChange: (value: unknown) => void;
}) {
  if (!operatorTakesValue(condition.operator)) {
    return <div className='h-8 flex-1 rounded-md border border-dashed bg-muted/30' aria-hidden />;
  }

  if ((field.variant === 'select' || field.variant === 'multiSelect') && field.options) {
    if (MULTI_VALUE_OPERATORS.includes(condition.operator)) {
      const selected = Array.isArray(condition.value) ? (condition.value as string[]) : [];
      return <MultiSelectValue options={field.options} selected={selected} onChange={onChange} />;
    }
    const selectedValue = (condition.value as string) ?? '';
    return (
      <Select value={selectedValue} onValueChange={(next: string | null) => onChange(next ?? '')}>
        <SelectTrigger className='h-8 flex-1'>
          <SelectValue placeholder='Select…'>
            {field.options.find((option) => option.value === selectedValue)?.label ?? (selectedValue || undefined)}
          </SelectValue>
        </SelectTrigger>
        <SelectContent>
          {field.options.map((option) => (
            <SelectItem key={option.value} value={option.value}>
              {option.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    );
  }

  return (
    <Input
      value={(condition.value as string) ?? ''}
      onChange={(event) => onChange(event.target.value)}
      type={field.variant === 'number' ? 'number' : field.variant === 'date' ? 'date' : 'text'}
      placeholder='Value'
      className='h-8 flex-1'
    />
  );
}

function FilterBuilder<TData>({ table }: { table: Table<TData> }) {
  const fields = getFilterableFields(table);
  const columnFilters = table.getState().columnFilters;

  const rows = columnFilters
    .map((columnFilter) => {
      const field = fields.find((candidate) => candidate.id === columnFilter.id);
      if (!field) return null;
      const condition = isFilterCondition(columnFilter.value)
        ? columnFilter.value
        : { operator: defaultOperatorFor(field.variant), value: columnFilter.value };
      return { field, condition };
    })
    .filter((row): row is { field: FilterField; condition: FilterCondition } => row !== null);

  const setCondition = (columnId: string, condition: FilterCondition) => {
    table.setColumnFilters((current) => [
      ...current.filter((item) => item.id !== columnId),
      { id: columnId, value: condition },
    ]);
  };

  const removeCondition = (columnId: string) =>
    table.setColumnFilters((current) => current.filter((item) => item.id !== columnId));

  const changeField = (oldId: string, newId: string) => {
    const field = fields.find((candidate) => candidate.id === newId);
    if (!field) return;
    table.setColumnFilters((current) => [
      ...current.filter((item) => item.id !== oldId && item.id !== newId),
      { id: newId, value: { operator: defaultOperatorFor(field.variant), value: defaultValueFor(field.variant) } },
    ]);
  };

  const changeOperator = (field: FilterField, previous: FilterCondition, operator: FilterOperator) => {
    const wasMulti = Array.isArray(previous.value);
    const willMulti = MULTI_VALUE_OPERATORS.includes(operator);
    const value = willMulti ? (wasMulti ? previous.value : []) : wasMulti ? '' : previous.value;
    setCondition(field.id, { operator, value });
  };

  const addCondition = () => {
    const used = new Set(columnFilters.map((item) => item.id));
    const next = fields.find((field) => !used.has(field.id));
    if (!next) return;
    setCondition(next.id, { operator: defaultOperatorFor(next.variant), value: defaultValueFor(next.variant) });
  };

  const allUsed = fields.length > 0 && fields.every((field) => columnFilters.some((item) => item.id === field.id));

  const sensors = useReorderSensors();
  const dndId = React.useId();
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    if (!over || active.id === over.id) return;
    table.setColumnFilters((current) => {
      const oldIndex = current.findIndex((item) => item.id === active.id);
      const newIndex = current.findIndex((item) => item.id === over.id);
      if (oldIndex < 0 || newIndex < 0) return current;
      return arrayMove(current, oldIndex, newIndex);
    });
  };

  return (
    <div className='flex flex-col gap-2'>
      {rows.length === 0 ? (
        <p className='px-1 py-2 text-muted-foreground text-sm'>No filters applied to this view.</p>
      ) : (
        <DndContext id={dndId} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
          <SortableContext items={rows.map((row) => row.field.id)} strategy={verticalListSortingStrategy}>
            <div className='flex flex-col gap-2'>
              {rows.map((row, index) => (
                <SortableRow key={row.field.id} id={row.field.id}>
                  <span className='w-12 shrink-0 text-muted-foreground text-sm'>{index === 0 ? 'Where' : 'And'}</span>
                  <FieldSelect
                    fields={fields}
                    value={row.field.id}
                    onChange={(value) => changeField(row.field.id, value)}
                  />
                  <OperatorSelect
                    variant={row.field.variant}
                    value={row.condition.operator}
                    onChange={(operator) => changeOperator(row.field, row.condition, operator)}
                  />
                  <FilterValueControl
                    field={row.field}
                    condition={row.condition}
                    onChange={(value) => setCondition(row.field.id, { ...row.condition, value })}
                  />
                  <Button
                    variant='ghost'
                    size='icon-sm'
                    className='shrink-0 text-muted-foreground'
                    onClick={() => removeCondition(row.field.id)}
                    aria-label='Remove filter'
                  >
                    <X className='size-4' />
                  </Button>
                </SortableRow>
              ))}
            </div>
          </SortableContext>
        </DndContext>
      )}
      <div className='flex items-center justify-between pt-1'>
        <Button
          variant='ghost'
          size='sm'
          className='h-8 px-2 text-muted-foreground'
          onClick={addCondition}
          disabled={allUsed}
        >
          <Plus className='mr-1.5 size-4' /> Add filter
        </Button>
        {rows.length > 0 && (
          <Button
            variant='ghost'
            size='sm'
            className='h-8 px-2 text-muted-foreground'
            onClick={() => table.setColumnFilters([])}
          >
            <Trash2 className='mr-1.5 size-4' /> Clear
          </Button>
        )}
      </div>
    </div>
  );
}

export type DataTableFilterMenuProps<TData> = {
  table: Table<TData>;
  variant?: 'popover' | 'dialog';
};

function FilterMenuButton({
  count,
  showChevron = false,
  ...props
}: React.ComponentProps<typeof Button> & { count: number; showChevron?: boolean }) {
  return (
    <Button variant='outline' size='sm' className='h-8 border-dashed' {...props}>
      <ListFilter className='mr-2 size-4' /> Filter
      {count > 0 && <span className='ml-2 rounded-sm bg-secondary px-1.5 font-normal text-xs'>{count}</span>}
      {showChevron && <ChevronDown className='ml-1 size-3.5 text-muted-foreground' />}
    </Button>
  );
}

export function DataTableFilterMenu<TData>({ table, variant = 'popover' }: DataTableFilterMenuProps<TData>) {
  const count = table.getState().columnFilters.length;

  if (variant === 'dialog') {
    return (
      <Dialog>
        <DialogTrigger render={<FilterMenuButton count={count} />} />
        <DialogContent className='max-w-2xl'>
          <DialogHeader>
            <DialogTitle>Filters</DialogTitle>
            <DialogDescription>Show rows that match all of these conditions.</DialogDescription>
          </DialogHeader>
          <FilterBuilder table={table} />
          <DialogFooter>
            <Button variant='ghost' size='sm' onClick={() => table.setColumnFilters([])}>
              Clear all
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    );
  }

  return (
    <Popover>
      <PopoverTrigger render={<FilterMenuButton count={count} showChevron />} />
      <PopoverContent align='start' className='w-[560px] p-2'>
        <FilterBuilder table={table} />
      </PopoverContent>
    </Popover>
  );
}

function SortBuilder<TData>({ table }: { table: Table<TData> }) {
  const fields = getSortableFields(table);
  const sorting = table.getState().sorting;

  const setDir = (id: string, desc: boolean) =>
    table.setSorting((current) => current.map((sort) => (sort.id === id ? { ...sort, desc } : sort)));

  const remove = (id: string) => table.setSorting((current) => current.filter((sort) => sort.id !== id));

  const changeField = (oldId: string, newId: string) =>
    table.setSorting((current) => [
      ...current.filter((sort) => sort.id !== oldId && sort.id !== newId),
      { id: newId, desc: false },
    ]);

  const add = () => {
    const used = new Set(sorting.map((sort) => sort.id));
    const next = fields.find((field) => !used.has(field.id));
    if (next) table.setSorting((current) => [...current, { id: next.id, desc: false }]);
  };

  const allUsed = fields.length > 0 && fields.every((field) => sorting.some((sort) => sort.id === field.id));

  const sensors = useReorderSensors();
  const dndId = React.useId();
  const handleDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    if (!over || active.id === over.id) return;
    table.setSorting((current) => {
      const oldIndex = current.findIndex((sort) => sort.id === active.id);
      const newIndex = current.findIndex((sort) => sort.id === over.id);
      if (oldIndex < 0 || newIndex < 0) return current;
      return arrayMove(current, oldIndex, newIndex);
    });
  };

  return (
    <div className='flex flex-col gap-2'>
      {sorting.length === 0 ? (
        <p className='px-1 py-2 text-muted-foreground text-sm'>No sorts applied to this view.</p>
      ) : (
        <DndContext id={dndId} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
          <SortableContext items={sorting.map((sort) => sort.id)} strategy={verticalListSortingStrategy}>
            <div className='flex flex-col gap-2'>
              {sorting.map((sort, index) => (
                <SortableRow key={sort.id} id={sort.id}>
                  <span className='w-12 shrink-0 text-muted-foreground text-sm'>{index === 0 ? 'Sort' : 'Then'}</span>
                  <FieldSelect fields={fields} value={sort.id} onChange={(value) => changeField(sort.id, value)} />
                  <Select
                    value={sort.desc ? 'desc' : 'asc'}
                    onValueChange={(value: string | null) => value !== null && setDir(sort.id, value === 'desc')}
                  >
                    <SelectTrigger className='h-8 flex-1'>
                      <SelectValue>{sort.desc ? 'Descending' : 'Ascending'}</SelectValue>
                    </SelectTrigger>
                    <SelectContent>
                      <SelectItem value='asc'>Ascending</SelectItem>
                      <SelectItem value='desc'>Descending</SelectItem>
                    </SelectContent>
                  </Select>
                  <Button
                    variant='ghost'
                    size='icon-sm'
                    className='shrink-0 text-muted-foreground'
                    onClick={() => remove(sort.id)}
                    aria-label='Remove sort'
                  >
                    <X className='size-4' />
                  </Button>
                </SortableRow>
              ))}
            </div>
          </SortableContext>
        </DndContext>
      )}
      <div className='pt-1'>
        <Button variant='ghost' size='sm' className='h-8 px-2 text-muted-foreground' onClick={add} disabled={allUsed}>
          <Plus className='mr-1.5 size-4' /> Add sort
        </Button>
      </div>
    </div>
  );
}

export function DataTableSortMenu<TData>({ table }: { table: Table<TData> }) {
  const count = table.getState().sorting.length;
  return (
    <Popover>
      <PopoverTrigger render={<Button variant='outline' size='sm' className='h-8 border-dashed' />}>
        <ArrowDownUp className='mr-2 size-4' /> Sort
        {count > 0 && <span className='ml-2 rounded-sm bg-secondary px-1.5 font-normal text-xs'>{count}</span>}
        <ChevronDown className='ml-1 size-3.5 text-muted-foreground' />
      </PopoverTrigger>
      <PopoverContent align='start' className='w-[420px] p-2'>
        <SortBuilder table={table} />
      </PopoverContent>
    </Popover>
  );
}

export type DataTableView = { name: string; filters: ColumnFiltersState; sorting: SortingState };

export type DataTableViewsProps<TData> = {
  table: Table<TData>;
  storageKey: string;
};

export function DataTableViews<TData>({ table, storageKey }: DataTableViewsProps<TData>) {
  const key = `shuip:dt-views:${storageKey}`;
  const [views, setViews] = React.useState<DataTableView[]>([]);
  const [name, setName] = React.useState('');
  const [saving, setSaving] = React.useState(false);

  React.useEffect(() => {
    try {
      const raw = localStorage.getItem(key);
      if (raw) setViews(JSON.parse(raw) as DataTableView[]);
    } catch {
      setViews([]);
    }
  }, [key]);

  const persist = (next: DataTableView[]) => {
    setViews(next);
    try {
      localStorage.setItem(key, JSON.stringify(next));
    } catch {
      /* storage unavailable */
    }
  };

  const save = () => {
    const trimmed = name.trim();
    if (!trimmed) return;
    const view: DataTableView = {
      name: trimmed,
      filters: table.getState().columnFilters,
      sorting: table.getState().sorting,
    };
    persist([...views.filter((current) => current.name !== trimmed), view]);
    setName('');
    setSaving(false);
  };

  const apply = (view: DataTableView) => {
    table.setColumnFilters(view.filters);
    table.setSorting(view.sorting);
  };

  const remove = (viewName: string) => persist(views.filter((current) => current.name !== viewName));

  return (
    <Popover onOpenChange={(open) => !open && setSaving(false)}>
      <PopoverTrigger render={<Button variant='outline' size='sm' className='h-8' />}>
        <Bookmark className='mr-2 size-4' /> Views
        {views.length > 0 && (
          <span className='ml-2 rounded-sm bg-secondary px-1.5 font-normal text-xs'>{views.length}</span>
        )}
        <ChevronDown className='ml-1 size-3.5 text-muted-foreground' />
      </PopoverTrigger>
      <PopoverContent align='end' className='w-60 p-1'>
        <div className='flex flex-col gap-0.5'>
          {views.length === 0 ? (
            <p className='px-2 py-1.5 text-muted-foreground text-sm'>No saved views.</p>
          ) : (
            views.map((view) => (
              <div key={view.name} className='flex items-center'>
                <button
                  type='button'
                  onClick={() => apply(view)}
                  className='flex-1 truncate rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent'
                >
                  {view.name}
                </button>
                <Button
                  variant='ghost'
                  size='icon-sm'
                  className='shrink-0 text-muted-foreground'
                  onClick={() => remove(view.name)}
                  aria-label={`Delete ${view.name}`}
                >
                  <Trash2 className='size-3.5' />
                </Button>
              </div>
            ))
          )}
        </div>
        <Separator className='my-1' />
        {saving ? (
          <div className='flex items-center gap-1.5 p-1'>
            <Input
              autoFocus
              value={name}
              onChange={(event) => setName(event.target.value)}
              onKeyDown={(event) => event.key === 'Enter' && save()}
              placeholder='View name'
              className='h-8'
            />
            <Button size='sm' className='h-8' onClick={save} disabled={!name.trim()}>
              Save
            </Button>
          </div>
        ) : (
          <Button variant='ghost' size='sm' className='w-full justify-start' onClick={() => setSaving(true)}>
            <Plus className='mr-2 size-4' /> Save current view
          </Button>
        )}
      </PopoverContent>
    </Popover>
  );
}

The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.

Loading...

DataTable is a generic table system (useDataTable<TData> + composable sub-components) that renders any array of typed rows. All per-entity configuration lives in your TanStack ColumnDef[] and a typed meta extension; the components only ever read the table instance, so nothing is hardcoded to a domain.

Built-in features

  • Generic data model: useDataTable<T> works with any row type via ColumnDef<T>[].
  • Dual mode: client-side out of the box (sorting, filtering, pagination computed in memory) or server-side by passing pageCount + controlled state and on*Change handlers.
  • Auto-generated toolbar: faceted filters are derived from each column's meta.variant / meta.options — no per-table wiring.
  • Composable: drop DataTableToolbar, DataTable, and DataTablePagination where you want them; swap any piece.
  • Per-zone variants: pagination (simple / numbered), progressive DataTableLoadMore (button / infinite), toolbar (default / inline-chips / minimal), empty state (text / illustrated / with-action), and loading (skeleton / overlay / shimmer).
  • Advanced filtering & sorting: a Notion-style condition builder (DataTableFilterMenu, popover or dialog), multi-sort (DataTableSortMenu), and saved views in localStorage (DataTableViews) — all derived from the same meta convention.
  • Row selection, column visibility, column pinning (sticky columns), sortable headers, global search.
  • States: loading skeleton, empty, and no-results-after-filter.

Examples

Advanced Filters

Loading...
'use client';

import type { ColumnDef } from '@tanstack/react-table';
import * as React from 'react';

import {
  DataTable,
  DataTableColumnHeader,
  DataTableFilterMenu,
  DataTablePagination,
  DataTableSortMenu,
  DataTableViews,
  useDataTable,
} from '@/components/block/shuip/data-table';
import { Button } from '@/components/ui/button';

type Deal = {
  id: string;
  company: string;
  stage: 'lead' | 'qualified' | 'won' | 'lost';
  amount: number;
  createdAt: string;
};

const stageOptions = [
  { label: 'Lead', value: 'lead' },
  { label: 'Qualified', value: 'qualified' },
  { label: 'Won', value: 'won' },
  { label: 'Lost', value: 'lost' },
];

const stages: Deal['stage'][] = ['lead', 'qualified', 'won', 'lost'];

const deals: Deal[] = Array.from({ length: 40 }, (_, index) => ({
  id: `deal-${index + 1}`,
  company: `Company ${String.fromCharCode(65 + (index % 26))}${index + 1}`,
  stage: stages[index % stages.length],
  amount: 1000 + ((index * 617) % 9000),
  createdAt: `2026-${String((index % 12) + 1).padStart(2, '0')}-${String((index % 27) + 1).padStart(2, '0')}`,
}));

export default function DataTableAdvancedFiltersExample() {
  const [surface, setSurface] = React.useState<'popover' | 'dialog'>('popover');

  const columns = React.useMemo<ColumnDef<Deal>[]>(
    () => [
      {
        accessorKey: 'company',
        size: 200,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Company' />,
        meta: { label: 'Company', variant: 'text' },
        cell: ({ row }) => <span className='font-medium'>{row.original.company}</span>,
      },
      {
        accessorKey: 'stage',
        size: 140,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Stage' />,
        meta: { label: 'Stage', variant: 'select', options: stageOptions },
        cell: ({ row }) => <span className='capitalize'>{row.original.stage}</span>,
      },
      {
        accessorKey: 'amount',
        size: 130,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Amount' />,
        meta: { label: 'Amount', variant: 'number' },
        cell: ({ row }) => <span className='tabular-nums'>${row.original.amount.toLocaleString()}</span>,
      },
      {
        accessorKey: 'createdAt',
        size: 150,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Created' />,
        meta: { label: 'Created', variant: 'date' },
        cell: ({ row }) => <span className='text-muted-foreground tabular-nums'>{row.original.createdAt}</span>,
      },
    ],
    [],
  );

  const { table } = useDataTable({
    data: deals,
    columns,
    getRowId: (row) => row.id,
    initialState: { pagination: { pageIndex: 0, pageSize: 8 } },
  });

  return (
    <div className='flex w-full min-w-0 flex-col gap-3'>
      <div className='flex flex-wrap items-center gap-2'>
        <DataTableFilterMenu table={table} variant={surface} />
        <DataTableSortMenu table={table} />
        <DataTableViews table={table} storageKey='deals' />
        <div className='ml-auto flex items-center gap-1 rounded-md bg-muted p-0.5'>
          {(['popover', 'dialog'] as const).map((value) => (
            <Button
              key={value}
              variant={surface === value ? 'default' : 'ghost'}
              size='sm'
              className='h-7 capitalize'
              onClick={() => setSurface(value)}
            >
              {value}
            </Button>
          ))}
        </div>
      </div>
      <DataTable table={table} />
      <DataTablePagination table={table} />
    </div>
  );
}

Empty

Loading...
'use client';

import type { ColumnDef } from '@tanstack/react-table';
import { UserPlus } from 'lucide-react';
import * as React from 'react';

import { DataTable, DataTableColumnHeader, DataTableEmpty, useDataTable } from '@/components/block/shuip/data-table';
import { Button } from '@/components/ui/button';

type User = {
  id: string;
  name: string;
  email: string;
};

const noUsers: User[] = [];

export default function DataTableEmptyExample() {
  const [variant, setVariant] = React.useState<'text' | 'illustrated' | 'with-action'>('illustrated');

  const columns = React.useMemo<ColumnDef<User>[]>(
    () => [
      {
        accessorKey: 'name',
        size: 220,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Name' />,
        meta: { label: 'Name' },
      },
      {
        accessorKey: 'email',
        size: 260,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Email' />,
        meta: { label: 'Email' },
      },
    ],
    [],
  );

  const { table } = useDataTable<User>({ data: noUsers, columns, getRowId: (row) => row.id });

  return (
    <div className='flex w-full min-w-0 flex-col gap-3'>
      <div className='flex items-center gap-2'>
        {(['text', 'illustrated', 'with-action'] as const).map((value) => (
          <Button
            key={value}
            variant={variant === value ? 'default' : 'outline'}
            size='sm'
            onClick={() => setVariant(value)}
          >
            {value}
          </Button>
        ))}
      </div>
      <DataTable
        table={table}
        emptyState={
          <DataTableEmpty
            variant={variant}
            icon={UserPlus}
            title='No users yet'
            description='Invite teammates to see them listed here.'
            action={<Button size='sm'>Invite user</Button>}
          />
        }
      />
    </div>
  );
}

Default

Loading...
'use client';

import type { ColumnDef } from '@tanstack/react-table';
import * as React from 'react';

import {
  DataTable,
  DataTableColumnHeader,
  DataTablePagination,
  DataTableToolbar,
  useDataTable,
} from '@/components/block/shuip/data-table';

type User = {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  status: 'active' | 'invited' | 'suspended';
};

const users: User[] = [
  { id: '1', name: 'Ava Mercer', email: 'ava@acme.io', role: 'admin', status: 'active' },
  { id: '2', name: 'Liam Cho', email: 'liam@acme.io', role: 'editor', status: 'active' },
  { id: '3', name: 'Noah Patel', email: 'noah@acme.io', role: 'editor', status: 'invited' },
  { id: '4', name: 'Mia Rossi', email: 'mia@acme.io', role: 'viewer', status: 'suspended' },
  { id: '5', name: 'Ethan Wood', email: 'ethan@acme.io', role: 'viewer', status: 'active' },
  { id: '6', name: 'Zoe Bauer', email: 'zoe@acme.io', role: 'admin', status: 'invited' },
  { id: '7', name: 'Kai Nguyen', email: 'kai@acme.io', role: 'editor', status: 'active' },
  { id: '8', name: 'Lena Fox', email: 'lena@acme.io', role: 'viewer', status: 'suspended' },
];

const roleOptions = [
  { label: 'Admin', value: 'admin' },
  { label: 'Editor', value: 'editor' },
  { label: 'Viewer', value: 'viewer' },
];

const statusOptions = [
  { label: 'Active', value: 'active' },
  { label: 'Invited', value: 'invited' },
  { label: 'Suspended', value: 'suspended' },
];

export default function DataTableDefaultExample() {
  const columns = React.useMemo<ColumnDef<User>[]>(
    () => [
      {
        accessorKey: 'name',
        size: 190,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Name' />,
        meta: { label: 'Name' },
        cell: ({ row }) => <span className='font-medium'>{row.original.name}</span>,
      },
      {
        accessorKey: 'email',
        size: 220,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Email' />,
        meta: { label: 'Email' },
        cell: ({ row }) => <span className='text-muted-foreground'>{row.original.email}</span>,
      },
      {
        accessorKey: 'role',
        size: 120,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Role' />,
        meta: { label: 'Role', variant: 'multiSelect', options: roleOptions },
        filterFn: 'arrIncludesSome',
        cell: ({ row }) => <span className='capitalize'>{row.original.role}</span>,
      },
      {
        accessorKey: 'status',
        size: 120,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Status' />,
        meta: { label: 'Status', variant: 'multiSelect', options: statusOptions },
        filterFn: 'arrIncludesSome',
        cell: ({ row }) => <span className='capitalize'>{row.original.status}</span>,
      },
    ],
    [],
  );

  const { table } = useDataTable({
    data: users,
    columns,
    getRowId: (row) => row.id,
    initialState: { pagination: { pageIndex: 0, pageSize: 5 } },
  });

  return (
    <div className='flex w-full min-w-0 flex-col gap-3'>
      <DataTableToolbar table={table} searchPlaceholder='Search users...' />
      <DataTable table={table} />
      <DataTablePagination table={table} />
    </div>
  );
}

Load More

Loading...
'use client';

import type { ColumnDef } from '@tanstack/react-table';
import * as React from 'react';

import { DataTable, DataTableColumnHeader, DataTableLoadMore, useDataTable } from '@/components/block/shuip/data-table';
import { Button } from '@/components/ui/button';

type Event = {
  id: string;
  name: string;
  channel: string;
  at: string;
};

const channels = ['web', 'mobile', 'api', 'import'];

const allEvents: Event[] = Array.from({ length: 60 }, (_, index) => ({
  id: `evt-${index + 1}`,
  name: `event.${['created', 'updated', 'deleted', 'viewed'][index % 4]}`,
  channel: channels[index % channels.length],
  at: `2026-06-${String((index % 28) + 1).padStart(2, '0')} 09:${String(index % 60).padStart(2, '0')}`,
}));

const PAGE = 10;

export default function DataTableLoadMoreExample() {
  const [mode, setMode] = React.useState<'button' | 'infinite'>('button');
  const [loaded, setLoaded] = React.useState(PAGE);
  const [isLoading, setIsLoading] = React.useState(false);

  const rows = React.useMemo(() => allEvents.slice(0, loaded), [loaded]);
  const hasMore = loaded < allEvents.length;

  const onLoadMore = React.useCallback(() => {
    setIsLoading(true);
    const timer = setTimeout(() => {
      setLoaded((current) => Math.min(current + PAGE, allEvents.length));
      setIsLoading(false);
    }, 500);
    return () => clearTimeout(timer);
  }, []);

  const columns = React.useMemo<ColumnDef<Event>[]>(
    () => [
      {
        accessorKey: 'name',
        size: 200,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Event' />,
        meta: { label: 'Event' },
        cell: ({ row }) => <span className='font-medium'>{row.original.name}</span>,
      },
      {
        accessorKey: 'channel',
        size: 140,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Channel' />,
        meta: { label: 'Channel' },
        cell: ({ row }) => <span className='capitalize'>{row.original.channel}</span>,
      },
      {
        accessorKey: 'at',
        size: 180,
        header: ({ column }) => <DataTableColumnHeader column={column} title='When' />,
        meta: { label: 'When' },
        cell: ({ row }) => <span className='text-muted-foreground tabular-nums'>{row.original.at}</span>,
      },
    ],
    [],
  );

  const { table } = useDataTable({
    data: rows,
    columns,
    getRowId: (row) => row.id,
    initialState: { pagination: { pageIndex: 0, pageSize: allEvents.length } },
  });

  return (
    <div className='flex w-full min-w-0 flex-col gap-3'>
      <div className='flex items-center gap-2'>
        <Button variant={mode === 'button' ? 'default' : 'outline'} size='sm' onClick={() => setMode('button')}>
          Button
        </Button>
        <Button variant={mode === 'infinite' ? 'default' : 'outline'} size='sm' onClick={() => setMode('infinite')}>
          Infinite
        </Button>
      </div>
      <DataTable table={table} />
      <DataTableLoadMore
        mode={mode}
        onLoadMore={onLoadMore}
        hasMore={hasMore}
        isLoading={isLoading}
        loaded={rows.length}
        total={allEvents.length}
      />
    </div>
  );
}

Loading

Loading...
'use client';

import type { ColumnDef } from '@tanstack/react-table';
import * as React from 'react';

import {
  DataTable,
  DataTableColumnHeader,
  DataTablePagination,
  useDataTable,
} from '@/components/block/shuip/data-table';
import { Button } from '@/components/ui/button';

type Project = {
  id: string;
  name: string;
  owner: string;
  status: 'active' | 'archived';
};

const projects: Project[] = [
  { id: '1', name: 'Apollo', owner: 'Ava Mercer', status: 'active' },
  { id: '2', name: 'Borealis', owner: 'Liam Cho', status: 'active' },
  { id: '3', name: 'Cobalt', owner: 'Noah Patel', status: 'archived' },
  { id: '4', name: 'Driftwood', owner: 'Mia Rossi', status: 'active' },
  { id: '5', name: 'Ember', owner: 'Ethan Wood', status: 'archived' },
];

export default function DataTableLoadingExample() {
  const [variant, setVariant] = React.useState<'skeleton' | 'overlay' | 'shimmer'>('skeleton');
  const [isLoading, setIsLoading] = React.useState(false);

  const trigger = React.useCallback((next: 'skeleton' | 'overlay' | 'shimmer') => {
    setVariant(next);
    setIsLoading(true);
    setTimeout(() => setIsLoading(false), 2000);
  }, []);

  const columns = React.useMemo<ColumnDef<Project>[]>(
    () => [
      {
        accessorKey: 'name',
        size: 200,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Project' />,
        meta: { label: 'Project' },
        cell: ({ row }) => <span className='font-medium'>{row.original.name}</span>,
      },
      {
        accessorKey: 'owner',
        size: 220,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Owner' />,
        meta: { label: 'Owner' },
      },
      {
        accessorKey: 'status',
        size: 120,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Status' />,
        meta: { label: 'Status' },
        cell: ({ row }) => <span className='capitalize'>{row.original.status}</span>,
      },
    ],
    [],
  );

  const { table } = useDataTable({ data: projects, columns, getRowId: (row) => row.id });

  return (
    <div className='flex w-full min-w-0 flex-col gap-3'>
      <div className='flex items-center gap-2'>
        {(['skeleton', 'overlay', 'shimmer'] as const).map((value) => (
          <Button
            key={value}
            variant={variant === value ? 'default' : 'outline'}
            size='sm'
            onClick={() => trigger(value)}
            disabled={isLoading}
          >
            {value}
          </Button>
        ))}
      </div>
      <DataTable table={table} isLoading={isLoading} loadingVariant={variant} />
      <DataTablePagination table={table} />
    </div>
  );
}

Pagination Numbered

Loading...
'use client';

import type { ColumnDef } from '@tanstack/react-table';
import * as React from 'react';

import {
  DataTable,
  DataTableColumnHeader,
  DataTablePagination,
  useDataTable,
} from '@/components/block/shuip/data-table';

type Invoice = {
  id: string;
  customer: string;
  amount: number;
  status: 'paid' | 'pending' | 'overdue';
};

const statuses: Invoice['status'][] = ['paid', 'pending', 'overdue'];

const invoices: Invoice[] = Array.from({ length: 84 }, (_, index) => ({
  id: `INV-${String(index + 1).padStart(4, '0')}`,
  customer: `Customer ${index + 1}`,
  amount: 100 + ((index * 37) % 900),
  status: statuses[index % statuses.length],
}));

export default function DataTablePaginationNumberedExample() {
  const columns = React.useMemo<ColumnDef<Invoice>[]>(
    () => [
      {
        accessorKey: 'id',
        size: 140,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Invoice' />,
        meta: { label: 'Invoice' },
        cell: ({ row }) => <span className='font-medium'>{row.original.id}</span>,
      },
      {
        accessorKey: 'customer',
        size: 220,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Customer' />,
        meta: { label: 'Customer' },
      },
      {
        accessorKey: 'amount',
        size: 120,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Amount' />,
        meta: { label: 'Amount' },
        cell: ({ row }) => <span className='tabular-nums'>${row.original.amount}</span>,
      },
      {
        accessorKey: 'status',
        size: 120,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Status' />,
        meta: { label: 'Status' },
        cell: ({ row }) => <span className='capitalize'>{row.original.status}</span>,
      },
    ],
    [],
  );

  const { table } = useDataTable({
    data: invoices,
    columns,
    getRowId: (row) => row.id,
    initialState: { pagination: { pageIndex: 0, pageSize: 8 } },
  });

  return (
    <div className='flex w-full min-w-0 flex-col gap-3'>
      <DataTable table={table} />
      <DataTablePagination table={table} variant='numbered' />
    </div>
  );
}

Rich

Loading...
'use client';

import type { ColumnDef } from '@tanstack/react-table';
import * as React from 'react';
import {
  DataTable,
  DataTableColumnHeader,
  DataTablePagination,
  DataTableToolbar,
  useDataTable,
} from '@/components/block/shuip/data-table';
import { Checkbox } from '@/components/ui/checkbox';

type Person = {
  id: string;
  name: string;
  email: string;
  team: string;
  stage: 'lead' | 'qualified' | 'won';
  tags: string[];
};

const people: Person[] = [
  { id: '1', name: 'Ava Mercer', email: 'ava@acme.io', team: 'Growth', stage: 'won', tags: ['VIP', 'EU'] },
  { id: '2', name: 'Liam Cho', email: 'liam@acme.io', team: 'Sales', stage: 'qualified', tags: ['US'] },
  { id: '3', name: 'Noah Patel', email: 'noah@acme.io', team: 'Sales', stage: 'lead', tags: ['Inbound'] },
  { id: '4', name: 'Mia Rossi', email: 'mia@acme.io', team: 'Success', stage: 'won', tags: ['VIP'] },
  { id: '5', name: 'Ethan Wood', email: 'ethan@acme.io', team: 'Growth', stage: 'lead', tags: ['EU', 'Trial'] },
  { id: '6', name: 'Zoe Bauer', email: 'zoe@acme.io', team: 'Success', stage: 'qualified', tags: ['US'] },
];

const stageStyles: Record<Person['stage'], string> = {
  lead: 'bg-muted text-muted-foreground',
  qualified: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
  won: 'bg-green-500/15 text-green-600 dark:text-green-400',
};

const stageOptions = [
  { label: 'Lead', value: 'lead' },
  { label: 'Qualified', value: 'qualified' },
  { label: 'Won', value: 'won' },
];

function Avatar({ name }: { name: string }) {
  const initials = name
    .split(' ')
    .map((part) => part[0])
    .slice(0, 2)
    .join('');
  return (
    <span className='flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10 font-medium text-primary text-xs'>
      {initials}
    </span>
  );
}

export default function DataTableRichExample() {
  const columns = React.useMemo<ColumnDef<Person>[]>(
    () => [
      {
        id: 'select',
        header: ({ table }) => (
          <Checkbox
            checked={table.getIsAllPageRowsSelected()}
            indeterminate={table.getIsSomePageRowsSelected()}
            onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
            aria-label='Select all'
          />
        ),
        cell: ({ row }) => (
          <Checkbox
            checked={row.getIsSelected()}
            onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
            aria-label='Select row'
          />
        ),
        enableSorting: false,
        enableHiding: false,
        size: 48,
      },
      {
        accessorKey: 'name',
        size: 220,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Name' />,
        meta: { label: 'Name' },
        cell: ({ row }) => (
          <div className='flex items-center gap-2'>
            <Avatar name={row.original.name} />
            <span className='font-medium'>{row.original.name}</span>
          </div>
        ),
      },
      {
        accessorKey: 'email',
        size: 240,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Email' />,
        meta: { label: 'Email' },
        cell: ({ row }) => <span className='text-muted-foreground'>{row.original.email}</span>,
      },
      {
        accessorKey: 'team',
        size: 150,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Team' />,
        meta: { label: 'Team' },
      },
      {
        accessorKey: 'stage',
        size: 150,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Stage' />,
        meta: { label: 'Stage', variant: 'multiSelect', options: stageOptions },
        filterFn: 'arrIncludesSome',
        cell: ({ row }) => (
          <span
            className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium text-xs capitalize ${stageStyles[row.original.stage]}`}
          >
            {row.original.stage}
          </span>
        ),
      },
      {
        accessorKey: 'tags',
        size: 180,
        header: 'Tags',
        enableSorting: false,
        meta: { label: 'Tags' },
        cell: ({ row }) => (
          <div className='flex flex-wrap gap-1'>
            {row.original.tags.map((tag) => (
              <span key={tag} className='rounded border bg-muted px-1.5 py-0.5 text-muted-foreground text-xs'>
                {tag}
              </span>
            ))}
          </div>
        ),
      },
    ],
    [],
  );

  const { table } = useDataTable({
    data: people,
    columns,
    getRowId: (row) => row.id,
    enableRowSelection: true,
    enableColumnPinning: true,
    initialState: { columnPinning: { left: ['select', 'name'], right: [] } },
  });

  return (
    <div className='flex w-full min-w-0 flex-col gap-3'>
      <DataTableToolbar table={table} searchPlaceholder='Search people...' />
      <DataTable table={table} />
      <DataTablePagination table={table} />
    </div>
  );
}

Server

Loading...
'use client';

import type { ColumnDef, ColumnFiltersState, PaginationState, SortingState } from '@tanstack/react-table';
import * as React from 'react';

import {
  DataTable,
  DataTableColumnHeader,
  DataTablePagination,
  DataTableToolbar,
  useDataTable,
} from '@/components/block/shuip/data-table';

type Order = {
  id: string;
  customer: string;
  total: number;
  status: 'paid' | 'pending' | 'refunded';
};

const STATUSES: Order['status'][] = ['paid', 'pending', 'refunded'];
const CUSTOMERS = ['Acme', 'Globex', 'Initech', 'Umbrella', 'Hooli', 'Soylent', 'Stark', 'Wayne'];

const ALL_ORDERS: Order[] = Array.from({ length: 47 }, (_, index) => ({
  id: `ORD-${1000 + index}`,
  customer: CUSTOMERS[index % CUSTOMERS.length],
  total: Math.round(50 + ((index * 73) % 950)),
  status: STATUSES[index % STATUSES.length],
}));

const statusOptions = [
  { label: 'Paid', value: 'paid' },
  { label: 'Pending', value: 'pending' },
  { label: 'Refunded', value: 'refunded' },
];

type QueryArgs = {
  pagination: PaginationState;
  sorting: SortingState;
  columnFilters: ColumnFiltersState;
  globalFilter: string;
};

function queryOrders({ pagination, sorting, columnFilters, globalFilter }: QueryArgs): {
  rows: Order[];
  total: number;
} {
  let rows = [...ALL_ORDERS];

  const search = globalFilter.trim().toLowerCase();
  if (search) {
    rows = rows.filter((order) => `${order.id} ${order.customer}`.toLowerCase().includes(search));
  }

  const statusFilter = columnFilters.find((filter) => filter.id === 'status')?.value as string[] | undefined;
  if (statusFilter?.length) {
    rows = rows.filter((order) => statusFilter.includes(order.status));
  }

  const sort = sorting[0];
  if (sort) {
    rows.sort((a, b) => {
      const left = a[sort.id as keyof Order];
      const right = b[sort.id as keyof Order];
      if (left < right) return sort.desc ? 1 : -1;
      if (left > right) return sort.desc ? -1 : 1;
      return 0;
    });
  }

  const total = rows.length;
  const start = pagination.pageIndex * pagination.pageSize;
  return { rows: rows.slice(start, start + pagination.pageSize), total };
}

export default function DataTableServerExample() {
  const [pagination, setPagination] = React.useState<PaginationState>({ pageIndex: 0, pageSize: 10 });
  const [sorting, setSorting] = React.useState<SortingState>([]);
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
  const [globalFilter, setGlobalFilter] = React.useState('');
  const [result, setResult] = React.useState<{ rows: Order[]; total: number }>({ rows: [], total: 0 });
  const [isLoading, setIsLoading] = React.useState(true);

  React.useEffect(() => {
    setIsLoading(true);
    const timer = setTimeout(() => {
      setResult(queryOrders({ pagination, sorting, columnFilters, globalFilter }));
      setIsLoading(false);
    }, 400);
    return () => clearTimeout(timer);
  }, [pagination, sorting, columnFilters, globalFilter]);

  const columns = React.useMemo<ColumnDef<Order>[]>(
    () => [
      {
        accessorKey: 'id',
        size: 130,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Order' />,
        meta: { label: 'Order' },
        cell: ({ row }) => <span className='font-medium'>{row.original.id}</span>,
      },
      {
        accessorKey: 'customer',
        size: 210,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Customer' />,
        meta: { label: 'Customer' },
      },
      {
        accessorKey: 'total',
        size: 110,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Total' />,
        meta: { label: 'Total' },
        cell: ({ row }) => <span className='tabular-nums'>${row.original.total}</span>,
      },
      {
        accessorKey: 'status',
        size: 130,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Status' />,
        meta: { label: 'Status', variant: 'multiSelect', options: statusOptions },
        cell: ({ row }) => <span className='capitalize'>{row.original.status}</span>,
      },
    ],
    [],
  );

  const { table } = useDataTable({
    data: result.rows,
    columns,
    pageCount: Math.ceil(result.total / pagination.pageSize),
    getRowId: (row) => row.id,
    state: { pagination, sorting, columnFilters, globalFilter },
    onPaginationChange: setPagination,
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onGlobalFilterChange: setGlobalFilter,
  });

  return (
    <div className='flex w-full min-w-0 flex-col gap-3'>
      <DataTableToolbar table={table} searchPlaceholder='Search orders...' />
      <DataTable table={table} isLoading={isLoading} />
      <DataTablePagination table={table} />
    </div>
  );
}

Toolbar Chips

Loading...
'use client';

import type { ColumnDef, ColumnFiltersState } from '@tanstack/react-table';
import * as React from 'react';

import {
  DataTable,
  DataTableColumnHeader,
  DataTablePagination,
  DataTableToolbar,
  useDataTable,
} from '@/components/block/shuip/data-table';

type User = {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  status: 'active' | 'invited' | 'suspended';
};

const users: User[] = [
  { id: '1', name: 'Ava Mercer', email: 'ava@acme.io', role: 'admin', status: 'active' },
  { id: '2', name: 'Liam Cho', email: 'liam@acme.io', role: 'editor', status: 'active' },
  { id: '3', name: 'Noah Patel', email: 'noah@acme.io', role: 'editor', status: 'invited' },
  { id: '4', name: 'Mia Rossi', email: 'mia@acme.io', role: 'viewer', status: 'suspended' },
  { id: '5', name: 'Ethan Wood', email: 'ethan@acme.io', role: 'viewer', status: 'active' },
  { id: '6', name: 'Zoe Bauer', email: 'zoe@acme.io', role: 'admin', status: 'invited' },
];

const roleOptions = [
  { label: 'Admin', value: 'admin' },
  { label: 'Editor', value: 'editor' },
  { label: 'Viewer', value: 'viewer' },
];

const statusOptions = [
  { label: 'Active', value: 'active' },
  { label: 'Invited', value: 'invited' },
  { label: 'Suspended', value: 'suspended' },
];

export default function DataTableToolbarChipsExample() {
  const columns = React.useMemo<ColumnDef<User>[]>(
    () => [
      {
        accessorKey: 'name',
        size: 190,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Name' />,
        meta: { label: 'Name' },
        cell: ({ row }) => <span className='font-medium'>{row.original.name}</span>,
      },
      {
        accessorKey: 'email',
        size: 220,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Email' />,
        meta: { label: 'Email' },
        cell: ({ row }) => <span className='text-muted-foreground'>{row.original.email}</span>,
      },
      {
        accessorKey: 'role',
        size: 120,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Role' />,
        meta: { label: 'Role', variant: 'multiSelect', options: roleOptions },
        filterFn: 'arrIncludesSome',
        cell: ({ row }) => <span className='capitalize'>{row.original.role}</span>,
      },
      {
        accessorKey: 'status',
        size: 120,
        header: ({ column }) => <DataTableColumnHeader column={column} title='Status' />,
        meta: { label: 'Status', variant: 'multiSelect', options: statusOptions },
        filterFn: 'arrIncludesSome',
        cell: ({ row }) => <span className='capitalize'>{row.original.status}</span>,
      },
    ],
    [],
  );

  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([
    { id: 'role', value: ['admin', 'editor'] },
  ]);

  const { table } = useDataTable({
    data: users,
    columns,
    getRowId: (row) => row.id,
    state: { columnFilters },
    onColumnFiltersChange: setColumnFilters,
  });

  return (
    <div className='flex w-full min-w-0 flex-col gap-3'>
      <DataTableToolbar table={table} variant='inline-chips' searchPlaceholder='Search users...' />
      <DataTable table={table} />
      <DataTablePagination table={table} />
    </div>
  );
}

Variants

Each zone of the table is a separate sub-component with its own variant, so you compose the look you want by swapping the relevant piece — nothing else changes.

// Pagination — page-based presentation of the same table state
<DataTablePagination table={table} variant='simple' />   // arrows + "Page X of Y" (default)
<DataTablePagination table={table} variant='numbered' />  // numbered pages with truncation

// Progressive loading — accumulate rows instead of paging (server- or client-driven)
<DataTableLoadMore mode='button' onLoadMore={loadMore} hasMore={hasMore} isLoading={isLoading} loaded={rows.length} total={total} />
<DataTableLoadMore mode='infinite' onLoadMore={loadMore} hasMore={hasMore} isLoading={isLoading} />

// Toolbar
<DataTableToolbar table={table} variant='default' />       // search + faceted filters + view (default)
<DataTableToolbar table={table} variant='inline-chips' />  // active filters as removable chips on a second row
<DataTableToolbar table={table} variant='minimal' />       // search + view only

// Empty state — pass a ready-made brick to `emptyState`, or any node of your own
<DataTable
  table={table}
  emptyState={
    <DataTableEmpty variant='illustrated' icon={Inbox} title='No results' description='Try adjusting your filters.' />
  }
/>
<DataTableEmpty variant='with-action' title='No users yet' action={<Button size='sm'>Invite user</Button>} />

// Loading
<DataTable table={table} isLoading={isLoading} loadingVariant='skeleton' />  // skeleton rows (default)
<DataTable table={table} isLoading={isLoading} loadingVariant='overlay' />   // keep rows, dim + spinner (best for refetch)
<DataTable table={table} isLoading={isLoading} loadingVariant='shimmer' />   // animated sweep (reduced-motion aware)

Advanced filtering, sorting & saved views

Three composable controls — drop them in any row, no toolbar assumed. Each is driven by the table instance alone; the filterable fields and their operators are derived from each column's existing meta, so there is no separate filter config to keep in sync.

<div className='flex items-center gap-2'>
  <DataTableFilterMenu table={table} variant='popover' />  {/* or 'dialog' */}
  <DataTableSortMenu table={table} />
  <DataTableViews table={table} storageKey='deals' />
</div>
  • DataTableFilterMenu — a condition builder (Where [field] [operator] [value]); rows reorder by dragging the handle. Operators are inferred from meta.variant: text → contains / is / is empty…, number → = ≠ > < ≥ ≤, date → before / after / on or before…, select / multiSelect → is / is any of / is none of. The variant prop renders the same builder in a popover or a dialog.
  • DataTableSortMenu — multi-column sort; order is priority (first row = primary), and rows reorder by dragging the handle. Secondary columns show a priority badge in their header.
  • DataTableViews — save the current { filters, sorting } as a named view in localStorage (shuip:dt-views:<storageKey>), then apply or delete it.

Conditions live in TanStack's columnFilters as { id, value: { operator, value } }. In client mode useDataTable registers an operator-aware filterFn (dataTableFilterFn), so filtering works with no per-column wiring; in server mode, read table.getState().columnFilters and translate it to your query. One condition per column (the columnFilters model); conditions combine with AND.

The meta convention

Per-entity configuration rides on TanStack's ColumnMeta, augmented by this block:

interface ColumnMeta {
  label?: string;
  placeholder?: string;
  variant?: 'text' | 'number' | 'date' | 'select' | 'multiSelect';
  options?: { label: string; value: string; icon?: React.ComponentType; count?: number }[];
  icon?: React.ComponentType;
}

A column with meta.variant: 'multiSelect' and meta.options automatically gets a faceted filter in the toolbar. For client-side multi-select filtering, set filterFn: 'arrIncludesSome' on that column.

Client vs server mode

Client mode is the default — pass data and columns and the table computes everything:

const { table } = useDataTable({ data, columns, getRowId: (row) => row.id });

Server mode activates when you pass pageCount. Control the state yourself and react to changes by refetching:

const { table } = useDataTable({
  data: result.rows,
  columns,
  pageCount: Math.ceil(result.total / pagination.pageSize),
  state: { pagination, sorting, columnFilters, globalFilter },
  onPaginationChange: setPagination,
  onSortingChange: setSorting,
  onColumnFiltersChange: setColumnFilters,
  onGlobalFilterChange: setGlobalFilter,
});

Recipe: sync state to the URL with nuqs

Server mode pairs naturally with shareable, bookmarkable URLs. This is opt-in and not bundled with the block (it would force a router dependency on every consumer). Install nuqs and wrap your app once:

bun add nuqs
// app/layout.tsx
import { NuqsAdapter } from 'nuqs/adapters/next/app';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang='en'>
      <body>
        <NuqsAdapter>{children}</NuqsAdapter>
      </body>
    </html>
  );
}

Then drop in this hook and feed its output straight into useDataTable:

// hooks/use-data-table-url-state.ts
'use client';

import type { ColumnFiltersState, PaginationState, SortingState } from '@tanstack/react-table';
import { parseAsInteger, parseAsJson, parseAsString, useQueryStates } from 'nuqs';

export function useDataTableUrlState() {
  const [state, setState] = useQueryStates(
    {
      pageIndex: parseAsInteger.withDefault(0),
      pageSize: parseAsInteger.withDefault(10),
      sorting: parseAsJson<SortingState>((value) => value as SortingState).withDefault([]),
      columnFilters: parseAsJson<ColumnFiltersState>((value) => value as ColumnFiltersState).withDefault([]),
      globalFilter: parseAsString.withDefault(''),
    },
    { history: 'push', shallow: false, throttleMs: 50, clearOnDefault: true },
  );

  const pagination: PaginationState = { pageIndex: state.pageIndex, pageSize: state.pageSize };

  return {
    pagination,
    sorting: state.sorting,
    columnFilters: state.columnFilters,
    globalFilter: state.globalFilter,
    onPaginationChange: (updater: PaginationState | ((old: PaginationState) => PaginationState)) => {
      const next = typeof updater === 'function' ? updater(pagination) : updater;
      void setState({ pageIndex: next.pageIndex, pageSize: next.pageSize });
    },
    onSortingChange: (updater: SortingState | ((old: SortingState) => SortingState)) => {
      const next = typeof updater === 'function' ? updater(state.sorting) : updater;
      void setState({ sorting: next, pageIndex: 0 });
    },
    onColumnFiltersChange: (updater: ColumnFiltersState | ((old: ColumnFiltersState) => ColumnFiltersState)) => {
      const next = typeof updater === 'function' ? updater(state.columnFilters) : updater;
      void setState({ columnFilters: next, pageIndex: 0 });
    },
    onGlobalFilterChange: (updater: string | ((old: string) => string)) => {
      const next = typeof updater === 'function' ? updater(state.globalFilter) : updater;
      void setState({ globalFilter: next, pageIndex: 0 });
    },
  };
}
const url = useDataTableUrlState();
const { table } = useDataTable({
  data: result.rows,
  columns,
  pageCount: Math.ceil(result.total / url.pagination.pageSize),
  state: url,
  onPaginationChange: url.onPaginationChange,
  onSortingChange: url.onSortingChange,
  onColumnFiltersChange: url.onColumnFiltersChange,
  onGlobalFilterChange: url.onGlobalFilterChange,
});

Props

Prop

Type

On this page