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'>
<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 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 (
<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 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'>
<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) => 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);
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={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) => (
<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} />;
}
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 (
<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 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>
<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';
};
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)} />
<Select
value={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>
<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 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'>
<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>
);
}
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() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
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