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 https://shuip.plvo.dev/r/data-table.json
pnpm dlx shadcn@latest add https://shuip.plvo.dev/r/data-table.json
bun x shadcn@latest add https://shuip.plvo.dev/r/data-table.json
'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,type FilterFn,flexRender,getCoreRowModel,getFacetedRowModel,getFacetedUniqueValues,getFilteredRowModel,getPaginationRowModel,getSortedRowModel,type OnChangeFn,type PaginationState,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: FilterFn<unknown> = (row, columnId, filterValue) => {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 table = useReactTable({data,columns,pageCount: pageCount ?? undefined,defaultColumn: { filterFn: dataTableFilterFn },state: {sorting: props.state?.sorting ?? sorting,columnFilters: props.state?.columnFilters ?? columnFilters,globalFilter: props.state?.globalFilter ?? globalFilter,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(),}),});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'><divclassName='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)}><TableRootclassName={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) => (<TableHeadkey={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) => (<TableRowkey={row.id}data-state={row.getIsSelected() ? 'selected' : undefined}onClick={onRowClick ? () => onRowClick(row.original) : undefined}className={onRowClick ? 'cursor-pointer' : undefined}>{row.getVisibleCells().map((cell) => (<TableCellkey={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 asChild><Button variant='ghost' size='sm' className={cn('-ml-3 h-8 data-[state=open]:bg-accent', 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>)}</Button></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 asChild><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></>)}</Button></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 (<CommandItemkey={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);}}><divclassName={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><CommandItemonSelect={() => 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 asChild><Button variant='outline' size='sm' className='ml-auto hidden h-8 lg:flex'><Settings2 className='mr-2 size-4' /> View</Button></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'><Inputplaceholder={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 (<DataTableFacetedFilterkey={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) => (<spankey={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}<buttontype='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) => 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'><Buttonvariant='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' ? (<spankey={`ellipsis-${index}`}className='flex size-8 items-center justify-center text-muted-foreground text-sm'>…</span>) : (<Buttonkey={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>),)}<Buttonvariant='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'><Buttonvariant='outline'size='icon-sm'className='hidden lg:flex'onClick={() => table.setPageIndex(0)}disabled={!table.getCanPreviousPage()}aria-label='First page'><ChevronsLeft className='size-4' /></Button><Buttonvariant='outline'size='icon-sm'onClick={() => table.previousPage()}disabled={!table.getCanPreviousPage()}aria-label='Previous page'><ChevronLeft className='size-4' /></Button><Buttonvariant='outline'size='icon-sm'onClick={() => table.nextPage()}disabled={!table.getCanNextPage()}aria-label='Next page'><ChevronRight className='size-4' /></Button><Buttonvariant='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);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')}><buttontype='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={onChange}><SelectTrigger className='h-8 w-[120px] shrink-0'><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) => onChange(next as FilterOperator)}><SelectTrigger className='h-8 w-[136px] shrink-0'><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 asChild><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' /></Button></PopoverTrigger><PopoverContent align='start' className='w-48 p-1'><div className='flex flex-col gap-0.5'>{options.map((option) => (<buttonkey={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} />;}return (<Select value={(condition.value as string) ?? ''} onValueChange={onChange}><SelectTrigger className='h-8 flex-1'><SelectValue placeholder='Select…' /></SelectTrigger><SelectContent>{field.options.map((option) => (<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>))}</SelectContent></Select>);}return (<Inputvalue={(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 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 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><FieldSelectfields={fields}value={row.field.id}onChange={(value) => changeField(row.field.id, value)}/><OperatorSelectvariant={row.field.variant}value={row.condition.operator}onChange={(operator) => changeOperator(row.field, row.condition, operator)}/><FilterValueControlfield={row.field}condition={row.condition}onChange={(value) => setCondition(row.field.id, { ...row.condition, value })}/><Buttonvariant='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'><Buttonvariant='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 && (<Buttonvariant='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';};export function DataTableFilterMenu<TData>({ table, variant = 'popover' }: DataTableFilterMenuProps<TData>) {const count = table.getState().columnFilters.length;const trigger = (<Button variant='outline' size='sm' className='h-8 border-dashed'><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>}{variant === 'popover' && <ChevronDown className='ml-1 size-3.5 text-muted-foreground' />}</Button>);if (variant === 'dialog') {return (<Dialog><DialogTrigger asChild>{trigger}</DialogTrigger><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 asChild>{trigger}</PopoverTrigger><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 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 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)} /><Selectvalue={sort.desc ? 'desc' : 'asc'}onValueChange={(value) => setDir(sort.id, value === 'desc')}><SelectTrigger className='h-8 flex-1'><SelectValue /></SelectTrigger><SelectContent><SelectItem value='asc'>Ascending</SelectItem><SelectItem value='desc'>Descending</SelectItem></SelectContent></Select><Buttonvariant='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 asChild><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' /></Button></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 asChild><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' /></Button></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'><buttontype='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><Buttonvariant='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'><InputautoFocusvalue={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>);}
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 viaColumnDef<T>[]. - Dual mode: client-side out of the box (sorting, filtering, pagination computed in memory) or server-side by passing
pageCount+ controlled state andon*Changehandlers. - Auto-generated toolbar: faceted filters are derived from each column's
meta.variant/meta.options— no per-table wiring. - Composable: drop
DataTableToolbar,DataTable, andDataTablePaginationwhere you want them; swap any piece. - Per-zone variants: pagination (
simple/numbered), progressiveDataTableLoadMore(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 samemetaconvention. - Row selection, column visibility, column pinning (sticky columns), sortable headers, global search.
- States: loading skeleton, empty, and no-results-after-filter.
Examples
Advanced Filters
'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) => (<Buttonkey={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
'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) => (<Buttonkey={value}variant={variant === value ? 'default' : 'outline'}size='sm'onClick={() => setVariant(value)}>{value}</Button>))}</div><DataTabletable={table}emptyState={<DataTableEmptyvariant={variant}icon={UserPlus}title='No users yet'description='Invite teammates to see them listed here.'action={<Button size='sm'>Invite user</Button>}/>}/></div>);}
Default
'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
'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} /><DataTableLoadMoremode={mode}onLoadMore={onLoadMore}hasMore={hasMore}isLoading={isLoading}loaded={rows.length}total={allEvents.length}/></div>);}
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) => (<Buttonkey={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
'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
'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 }) => (<Checkboxchecked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}aria-label='Select all'/>),cell: ({ row }) => (<Checkboxchecked={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 }) => (<spanclassName={`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
'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
'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 frommeta.variant:text→ contains / is / is empty…,number→= ≠ > < ≥ ≤,date→ before / after / on or before…,select/multiSelect→ is / is any of / is none of. Thevariantprop 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 inlocalStorage(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