Data Table
An entity-agnostic, dual-mode data table built on TanStack Table. Define your columns and drop in your data — client-side out of the box, or server-side with pagination, sorting, and filtering.
npx shadcn@latest add @shuip/data-table
pnpm dlx shadcn@latest add @shuip/data-table
bun x shadcn@latest add @shuip/data-table
'use client';
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
type Column,
type ColumnDef,
type ColumnFiltersState,
type ColumnPinningState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type OnChangeFn,
type PaginationState,
type Row,
type RowData,
type RowSelectionState,
type SortingState,
type Table,
useReactTable,
type VisibilityState,
} from '@tanstack/react-table';
import {
ArrowDown,
ArrowDownUp,
ArrowUp,
Bookmark,
CheckIcon,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronsUpDown,
EyeOff,
GripVertical,
Inbox,
ListFilter,
Loader2,
Plus,
PlusCircle,
Settings2,
Trash2,
X,
} from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { TableBody, TableCell, TableHead, TableHeader, Table as TableRoot, TableRow } from '@/components/ui/table';
import { cn } from '@/lib/utils';
export type DataTableFilterOption = {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }>;
count?: number;
};
declare module '@tanstack/react-table' {
interface ColumnMeta<TData extends RowData, TValue> {
label?: string;
placeholder?: string;
variant?: 'text' | 'number' | 'date' | 'select' | 'multiSelect';
options?: DataTableFilterOption[];
icon?: React.ComponentType<{ className?: string }>;
}
}
export type FilterVariant = NonNullable<DataTableColumnMetaVariant>;
type DataTableColumnMetaVariant = 'text' | 'number' | 'date' | 'select' | 'multiSelect';
export type FilterOperator =
| 'contains'
| 'notContains'
| 'is'
| 'isNot'
| 'isEmpty'
| 'isNotEmpty'
| 'eq'
| 'ne'
| 'gt'
| 'lt'
| 'gte'
| 'lte'
| 'before'
| 'after'
| 'onOrBefore'
| 'onOrAfter'
| 'isAnyOf'
| 'isNoneOf';
export type FilterCondition = { operator: FilterOperator; value: unknown };
const OPERATOR_LABELS: Record<FilterOperator, string> = {
contains: 'contains',
notContains: 'does not contain',
is: 'is',
isNot: 'is not',
isEmpty: 'is empty',
isNotEmpty: 'is not empty',
eq: '=',
ne: '≠',
gt: '>',
lt: '<',
gte: '≥',
lte: '≤',
before: 'is before',
after: 'is after',
onOrBefore: 'is on or before',
onOrAfter: 'is on or after',
isAnyOf: 'is any of',
isNoneOf: 'is none of',
};
const OPERATORS_BY_VARIANT: Record<FilterVariant, FilterOperator[]> = {
text: ['contains', 'notContains', 'is', 'isNot', 'isEmpty', 'isNotEmpty'],
number: ['eq', 'ne', 'gt', 'lt', 'gte', 'lte', 'isEmpty', 'isNotEmpty'],
date: ['is', 'before', 'after', 'onOrBefore', 'onOrAfter', 'isEmpty'],
select: ['is', 'isNot', 'isAnyOf', 'isNoneOf'],
multiSelect: ['isAnyOf', 'isNoneOf', 'is', 'isNot'],
};
const MULTI_VALUE_OPERATORS: FilterOperator[] = ['isAnyOf', 'isNoneOf'];
function operatorTakesValue(operator: FilterOperator): boolean {
return operator !== 'isEmpty' && operator !== 'isNotEmpty';
}
function isFilterCondition(value: unknown): value is FilterCondition {
return typeof value === 'object' && value !== null && 'operator' in value;
}
function conditionIsEmpty(condition: FilterCondition): boolean {
if (!operatorTakesValue(condition.operator)) return false;
const { value } = condition;
return value == null || value === '' || (Array.isArray(value) && value.length === 0);
}
export const dataTableFilterFn = <TData,>(row: Row<TData>, columnId: string, filterValue: unknown): boolean => {
if (Array.isArray(filterValue)) {
const cell = row.getValue(columnId);
return filterValue.length === 0 || filterValue.includes(cell);
}
if (!isFilterCondition(filterValue)) return true;
const { operator, value } = filterValue;
if (conditionIsEmpty(filterValue)) return true;
const cell = row.getValue(columnId);
const text = String(cell ?? '').toLowerCase();
const target = String(value ?? '').toLowerCase();
switch (operator) {
case 'isEmpty':
return cell == null || cell === '';
case 'isNotEmpty':
return !(cell == null || cell === '');
case 'contains':
return text.includes(target);
case 'notContains':
return !text.includes(target);
case 'is':
return text === target;
case 'isNot':
return text !== target;
case 'eq':
return Number(cell) === Number(value);
case 'ne':
return Number(cell) !== Number(value);
case 'gt':
return Number(cell) > Number(value);
case 'lt':
return Number(cell) < Number(value);
case 'gte':
return Number(cell) >= Number(value);
case 'lte':
return Number(cell) <= Number(value);
case 'before':
return new Date(String(cell)) < new Date(String(value));
case 'after':
return new Date(String(cell)) > new Date(String(value));
case 'onOrBefore':
return new Date(String(cell)) <= new Date(String(value));
case 'onOrAfter':
return new Date(String(cell)) >= new Date(String(value));
case 'isAnyOf':
return Array.isArray(value) ? value.includes(cell) : true;
case 'isNoneOf':
return Array.isArray(value) ? !value.includes(cell) : true;
default:
return true;
}
};
export type UseDataTableProps<TData> = {
data: TData[];
columns: ColumnDef<TData, unknown>[];
pageCount?: number;
getRowId?: (row: TData, index: number) => string;
enableRowSelection?: boolean;
enableColumnPinning?: boolean;
initialState?: {
pagination?: PaginationState;
sorting?: SortingState;
columnVisibility?: VisibilityState;
columnPinning?: ColumnPinningState;
};
state?: {
pagination?: PaginationState;
sorting?: SortingState;
columnFilters?: ColumnFiltersState;
globalFilter?: string;
};
onPaginationChange?: OnChangeFn<PaginationState>;
onSortingChange?: OnChangeFn<SortingState>;
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>;
onGlobalFilterChange?: OnChangeFn<string>;
};
export function useDataTable<TData>(props: UseDataTableProps<TData>) {
const {
data,
columns,
pageCount,
getRowId,
enableRowSelection = false,
enableColumnPinning = false,
initialState,
} = props;
const manual = pageCount != null;
const [sorting, setSorting] = React.useState<SortingState>(initialState?.sorting ?? []);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = React.useState('');
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(initialState?.columnVisibility ?? {});
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
const [pagination, setPagination] = React.useState<PaginationState>(
initialState?.pagination ?? { pageIndex: 0, pageSize: 10 },
);
const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>(() => ({
left: initialState?.columnPinning?.left ?? [],
right: initialState?.columnPinning?.right ?? [],
}));
const currentSorting = props.state?.sorting ?? sorting;
const currentColumnFilters = props.state?.columnFilters ?? columnFilters;
const currentGlobalFilter = props.state?.globalFilter ?? globalFilter;
const table = useReactTable<TData>({
data,
columns,
pageCount: pageCount ?? undefined,
defaultColumn: { filterFn: dataTableFilterFn },
autoResetPageIndex: false,
state: {
sorting: currentSorting,
columnFilters: currentColumnFilters,
globalFilter: currentGlobalFilter,
columnVisibility,
rowSelection,
pagination: props.state?.pagination ?? pagination,
columnPinning,
},
enableRowSelection,
enableColumnPinning,
getRowId,
manualPagination: manual,
manualSorting: manual,
manualFiltering: manual,
onSortingChange: props.onSortingChange ?? setSorting,
onColumnFiltersChange: props.onColumnFiltersChange ?? setColumnFilters,
onGlobalFilterChange: props.onGlobalFilterChange ?? setGlobalFilter,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onPaginationChange: props.onPaginationChange ?? setPagination,
onColumnPinningChange: setColumnPinning,
getCoreRowModel: getCoreRowModel(),
...(manual
? {}
: {
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
}),
});
// TanStack queues the reset of the page from the render that computes the rows, and a React that renders in slices,
// as it does when it hydrates, runs that microtask before the component has mounted. The reset runs here instead,
// from a layout effect so that no frame shows a page the new rows do not reach, and only when the rows can have
// changed. The inputs are compared through a ref, not a dependency array: an array would also run the effect on
// mount and on the second run of strict mode, and send a table that starts on `initialState.pagination` back to
// its first page.
const rowInputs = [data, currentSorting, currentColumnFilters, currentGlobalFilter];
const previousRowInputs = React.useRef(rowInputs);
React.useLayoutEffect(() => {
const previous = previousRowInputs.current;
previousRowInputs.current = rowInputs;
if (manual) return;
if (rowInputs.every((input, index) => Object.is(input, previous[index]))) return;
table.resetPageIndex();
});
return { table };
}
function getColumnStyles<TData>(column: Column<TData>): React.CSSProperties {
const pinned = column.getIsPinned();
return {
width: column.getSize(),
...(pinned
? {
position: 'sticky',
left: pinned === 'left' ? column.getStart('left') : undefined,
right: pinned === 'right' ? column.getAfter('right') : undefined,
zIndex: 1,
}
: {}),
};
}
function getPinClass<TData>(column: Column<TData>): string | undefined {
return column.getIsPinned() ? 'bg-background' : undefined;
}
const skeletonBar = <div className='h-5 w-full animate-pulse rounded bg-muted' />;
const shimmerBar = (
<div className='relative h-5 w-full overflow-hidden rounded bg-muted'>
<div
className='absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-foreground/10 to-transparent motion-reduce:hidden'
style={{ animation: 'shuip-dt-shimmer 1.5s infinite' }}
/>
</div>
);
const shimmerKeyframes = <style>{'@keyframes shuip-dt-shimmer{100%{transform:translateX(100%)}}'}</style>;
export type DataTableProps<TData> = {
table: Table<TData>;
isLoading?: boolean;
loadingVariant?: 'skeleton' | 'overlay' | 'shimmer';
emptyState?: React.ReactNode;
onRowClick?: (row: TData) => void;
className?: string;
};
export function DataTable<TData>({
table,
isLoading,
loadingVariant = 'skeleton',
emptyState,
onRowClick,
className,
}: DataTableProps<TData>) {
const columnCount = table.getVisibleLeafColumns().length;
const rows = table.getRowModel().rows;
const showSkeleton = isLoading && loadingVariant !== 'overlay';
const showOverlay = isLoading && loadingVariant === 'overlay';
return (
<div className={cn('relative rounded-md border', className)}>
<TableRoot
className={cn('table-fixed', showOverlay && 'pointer-events-none opacity-60')}
style={{ minWidth: table.getTotalSize() }}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
colSpan={header.colSpan}
style={getColumnStyles(header.column)}
className={getPinClass(header.column)}
>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{showSkeleton ? (
Array.from({ length: table.getState().pagination.pageSize }).map((_, rowIndex) => (
<TableRow key={rowIndex}>
{table.getVisibleLeafColumns().map((column) => (
<TableCell key={column.id} style={getColumnStyles(column)} className={getPinClass(column)}>
{loadingVariant === 'shimmer' ? shimmerBar : skeletonBar}
</TableCell>
))}
</TableRow>
))
) : rows.length ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() ? 'selected' : undefined}
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
className={onRowClick ? 'cursor-pointer' : undefined}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={getColumnStyles(cell.column)}
className={cn('overflow-hidden', getPinClass(cell.column))}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columnCount} className='h-24 text-center'>
{emptyState ?? 'No results.'}
</TableCell>
</TableRow>
)}
</TableBody>
</TableRoot>
{showOverlay && (
<div className='absolute inset-0 z-10 flex items-center justify-center bg-background/60 backdrop-blur-[1px]'>
<Loader2 className='size-5 animate-spin text-muted-foreground' />
</div>
)}
{loadingVariant === 'shimmer' && shimmerKeyframes}
</div>
);
}
export type DataTableEmptyProps = {
variant?: 'text' | 'illustrated' | 'with-action';
title?: string;
description?: string;
icon?: React.ComponentType<{ className?: string }>;
action?: React.ReactNode;
};
export function DataTableEmpty({
variant = 'text',
title = 'No results',
description,
icon: Icon = Inbox,
action,
}: DataTableEmptyProps) {
if (variant === 'text') {
return <span className='text-muted-foreground text-sm'>{title}</span>;
}
return (
<div className='flex flex-col items-center justify-center gap-3 py-6 text-center'>
<div className='flex size-12 items-center justify-center rounded-full bg-muted/50'>
<Icon className='size-6 text-muted-foreground' />
</div>
<div className='space-y-1'>
<p className='text-balance font-medium text-sm'>{title}</p>
{description && <p className='mx-auto max-w-xs text-balance text-muted-foreground text-sm'>{description}</p>}
</div>
{variant === 'with-action' && action}
</div>
);
}
export type DataTableColumnHeaderProps<TData, TValue> = {
column: Column<TData, TValue>;
title: string;
className?: string;
};
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort() && !column.getCanHide()) {
return <div className={className}>{title}</div>;
}
const sorted = column.getIsSorted();
return (
<Popover>
<PopoverTrigger render={<Button variant='ghost' size='sm' className={cn('-ml-3 h-8', className)} />}>
<span>{title}</span>
{sorted === 'desc' ? (
<ArrowDown className='ml-2 size-4' />
) : sorted === 'asc' ? (
<ArrowUp className='ml-2 size-4' />
) : (
<ChevronsUpDown className='ml-2 size-4' />
)}
{sorted && column.getSortIndex() >= 1 && (
<span className='ml-1 rounded bg-muted px-1 font-mono text-[10px] text-muted-foreground tabular-nums'>
{column.getSortIndex() + 1}
</span>
)}
</PopoverTrigger>
<PopoverContent align='start' className='flex w-40 flex-col gap-0.5 p-1'>
{column.getCanSort() && (
<>
<Button variant='ghost' size='sm' className='justify-start' onClick={() => column.toggleSorting(false)}>
<ArrowUp className='mr-2 size-3.5 text-muted-foreground/70' /> Asc
</Button>
<Button variant='ghost' size='sm' className='justify-start' onClick={() => column.toggleSorting(true)}>
<ArrowDown className='mr-2 size-3.5 text-muted-foreground/70' /> Desc
</Button>
</>
)}
{column.getCanHide() && (
<Button variant='ghost' size='sm' className='justify-start' onClick={() => column.toggleVisibility(false)}>
<EyeOff className='mr-2 size-3.5 text-muted-foreground/70' /> Hide
</Button>
)}
</PopoverContent>
</Popover>
);
}
export type DataTableFacetedFilterProps<TData, TValue> = {
column?: Column<TData, TValue>;
title?: string;
options: DataTableFilterOption[];
};
export function DataTableFacetedFilter<TData, TValue>({
column,
title,
options,
}: DataTableFacetedFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues();
const selectedValues = new Set((column?.getFilterValue() as string[]) ?? []);
return (
<Popover>
<PopoverTrigger render={<Button variant='outline' size='sm' className='h-8 border-dashed' />}>
<PlusCircle className='mr-2 size-4' />
{title}
{selectedValues.size > 0 && (
<>
<Separator orientation='vertical' className='mx-2 h-4' />
<span className='rounded-sm bg-secondary px-1 font-normal text-xs'>{selectedValues.size}</span>
</>
)}
</PopoverTrigger>
<PopoverContent className='w-48 p-0' align='start'>
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => {
const next = new Set(selectedValues);
if (isSelected) next.delete(option.value);
else next.add(option.value);
const filterValues = Array.from(next);
column?.setFilterValue(filterValues.length ? filterValues : undefined);
}}
>
<div
className={cn(
'mr-2 flex size-4 items-center justify-center rounded-sm border border-primary',
isSelected ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible',
)}
>
<CheckIcon className='size-3.5' />
</div>
{option.icon && <option.icon className='mr-2 size-4 text-muted-foreground' />}
<span>{option.label}</span>
{(facets?.get(option.value) ?? 0) > 0 && (
<span className='ml-auto flex size-4 items-center justify-center font-mono text-xs'>
{facets?.get(option.value)}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className='justify-center text-center'
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function DataTableViewOptions<TData>({ table }: { table: Table<TData> }) {
const columns = table
.getAllColumns()
.filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide());
return (
<Popover>
<PopoverTrigger render={<Button variant='outline' size='sm' className='ml-auto hidden h-8 lg:flex' />}>
<Settings2 className='mr-2 size-4' /> View
</PopoverTrigger>
<PopoverContent align='end' className='w-44 p-0'>
<Command>
<CommandInput placeholder='Search columns...' />
<CommandList>
<CommandEmpty>No columns.</CommandEmpty>
<CommandGroup>
{columns.map((column) => (
<CommandItem key={column.id} onSelect={() => column.toggleVisibility(!column.getIsVisible())}>
<CheckIcon className={cn('mr-2 size-4', column.getIsVisible() ? 'opacity-100' : 'opacity-0')} />
<span className='truncate'>{column.columnDef.meta?.label ?? column.id}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
type FilterChip = { key: string; label: string; onRemove: () => void };
function getFilterChips<TData>(table: Table<TData>): FilterChip[] {
const chips: FilterChip[] = [];
const globalFilter = table.getState().globalFilter as string;
if (globalFilter) {
chips.push({ key: 'global', label: `Search: ${globalFilter}`, onRemove: () => table.setGlobalFilter('') });
}
for (const filter of table.getState().columnFilters) {
const column = table.getColumn(filter.id);
const meta = column?.columnDef.meta;
const columnLabel = meta?.label ?? filter.id;
const values = Array.isArray(filter.value) ? (filter.value as string[]) : [filter.value as string];
for (const value of values) {
const optionLabel = meta?.options?.find((option) => option.value === value)?.label ?? String(value);
chips.push({
key: `${filter.id}-${value}`,
label: `${columnLabel}: ${optionLabel}`,
onRemove: () => {
const next = values.filter((item) => item !== value);
column?.setFilterValue(next.length ? next : undefined);
},
});
}
}
return chips;
}
export type DataTableToolbarProps<TData> = {
table: Table<TData>;
searchPlaceholder?: string;
variant?: 'default' | 'inline-chips' | 'minimal';
children?: React.ReactNode;
};
export function DataTableToolbar<TData>({
table,
searchPlaceholder = 'Search...',
variant = 'default',
children,
}: DataTableToolbarProps<TData>) {
const isFiltered = table.getState().columnFilters.length > 0 || Boolean(table.getState().globalFilter);
const showFacets = variant !== 'minimal';
const showChips = variant === 'inline-chips';
const filterableColumns = table
.getAllColumns()
.filter((column) => column.getCanFilter() && column.columnDef.meta?.variant);
const chips = showChips ? getFilterChips(table) : [];
const clearAll = () => {
table.resetColumnFilters();
table.setGlobalFilter('');
};
return (
<div className='flex flex-col gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<Input
placeholder={searchPlaceholder}
value={(table.getState().globalFilter as string) ?? ''}
onChange={(event) => table.setGlobalFilter(event.target.value)}
className='h-8 w-40 lg:w-56'
/>
{showFacets &&
filterableColumns.map((column) => {
const meta = column.columnDef.meta;
if ((meta?.variant === 'select' || meta?.variant === 'multiSelect') && meta.options) {
return (
<DataTableFacetedFilter
key={column.id}
column={column}
title={meta.label ?? column.id}
options={meta.options}
/>
);
}
return null;
})}
{isFiltered && !showChips && (
<Button variant='ghost' size='sm' className='h-8 px-2' onClick={clearAll}>
Reset <X className='ml-2 size-4' />
</Button>
)}
{children}
<DataTableViewOptions table={table} />
</div>
{showChips && chips.length > 0 && (
<div className='flex flex-wrap items-center gap-1.5'>
{chips.map((chip) => (
<span
key={chip.key}
className='inline-flex items-center gap-1 rounded-full bg-secondary py-0.5 pr-1 pl-2 text-secondary-foreground text-xs'
>
{chip.label}
<button
type='button'
onClick={chip.onRemove}
aria-label={`Remove ${chip.label}`}
className='flex size-4 items-center justify-center rounded-full hover:bg-background/60'
>
<X className='size-3' />
</button>
</span>
))}
<Button variant='ghost' size='sm' className='h-6 px-2 text-xs' onClick={clearAll}>
Clear all
</Button>
</div>
)}
</div>
);
}
function getPaginationRange(currentPage: number, pageCount: number, siblings = 1): (number | 'ellipsis')[] {
const totalPageNumbers = siblings * 2 + 5;
if (pageCount <= totalPageNumbers) {
return Array.from({ length: pageCount }, (_, index) => index + 1);
}
const leftSibling = Math.max(currentPage - siblings, 1);
const rightSibling = Math.min(currentPage + siblings, pageCount);
const showLeftEllipsis = leftSibling > 2;
const showRightEllipsis = rightSibling < pageCount - 1;
const edgeCount = 3 + 2 * siblings;
if (!showLeftEllipsis && showRightEllipsis) {
return [...Array.from({ length: edgeCount }, (_, index) => index + 1), 'ellipsis', pageCount];
}
if (showLeftEllipsis && !showRightEllipsis) {
return [1, 'ellipsis', ...Array.from({ length: edgeCount }, (_, index) => pageCount - edgeCount + 1 + index)];
}
return [
1,
'ellipsis',
...Array.from({ length: rightSibling - leftSibling + 1 }, (_, index) => leftSibling + index),
'ellipsis',
pageCount,
];
}
export type DataTablePaginationProps<TData> = {
table: Table<TData>;
pageSizeOptions?: number[];
variant?: 'simple' | 'numbered';
};
export function DataTablePagination<TData>({
table,
pageSizeOptions = [10, 20, 30, 40, 50],
variant = 'simple',
}: DataTablePaginationProps<TData>) {
const pageSize = table.getState().pagination.pageSize;
const pageIndex = table.getState().pagination.pageIndex;
const pageCount = Math.max(table.getPageCount(), 1);
const options = pageSizeOptions.includes(pageSize)
? pageSizeOptions
: [...pageSizeOptions, pageSize].sort((a, b) => a - b);
return (
<div className='flex flex-wrap items-center justify-between gap-4'>
<div className='text-muted-foreground text-sm'>
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className='flex flex-wrap items-center gap-4 lg:gap-6'>
<div className='flex items-center gap-2'>
<p className='font-medium text-sm'>Rows per page</p>
<Select
value={`${pageSize}`}
onValueChange={(value: string | null) => value !== null && table.setPageSize(Number(value))}
>
<SelectTrigger className='h-8 w-[72px]'>
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{options.map((size) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{variant === 'numbered' ? (
<div className='flex items-center gap-1'>
<Button
variant='outline'
size='icon-sm'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
aria-label='Previous page'
>
<ChevronLeft className='size-4' />
</Button>
{getPaginationRange(pageIndex + 1, pageCount).map((page, index) =>
page === 'ellipsis' ? (
<span
key={`ellipsis-${index}`}
className='flex size-8 items-center justify-center text-muted-foreground text-sm'
>
…
</span>
) : (
<Button
key={page}
variant={page === pageIndex + 1 ? 'default' : 'ghost'}
size='icon-sm'
className='tabular-nums'
aria-current={page === pageIndex + 1 ? 'page' : undefined}
onClick={() => table.setPageIndex(page - 1)}
>
{page}
</Button>
),
)}
<Button
variant='outline'
size='icon-sm'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
aria-label='Next page'
>
<ChevronRight className='size-4' />
</Button>
</div>
) : (
<>
<div className='flex items-center font-medium text-sm'>
Page {pageIndex + 1} of {pageCount}
</div>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='icon-sm'
className='hidden lg:flex'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
aria-label='First page'
>
<ChevronsLeft className='size-4' />
</Button>
<Button
variant='outline'
size='icon-sm'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
aria-label='Previous page'
>
<ChevronLeft className='size-4' />
</Button>
<Button
variant='outline'
size='icon-sm'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
aria-label='Next page'
>
<ChevronRight className='size-4' />
</Button>
<Button
variant='outline'
size='icon-sm'
className='hidden lg:flex'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
aria-label='Last page'
>
<ChevronsRight className='size-4' />
</Button>
</div>
</>
)}
</div>
</div>
);
}
export type DataTableLoadMoreProps = {
onLoadMore: () => void;
hasMore: boolean;
mode?: 'button' | 'infinite';
isLoading?: boolean;
loaded?: number;
total?: number;
};
export function DataTableLoadMore({
onLoadMore,
hasMore,
mode = 'button',
isLoading = false,
loaded,
total,
}: DataTableLoadMoreProps) {
const sentinelRef = React.useRef<HTMLDivElement>(null);
const onLoadMoreRef = React.useRef(onLoadMore);
// Resynchronised on commit, never during render: a discarded render must not
// leave its callback where the observer can call it.
React.useEffect(() => {
onLoadMoreRef.current = onLoadMore;
});
React.useEffect(() => {
if (mode !== 'infinite' || !hasMore || isLoading) return;
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) onLoadMoreRef.current();
},
{ rootMargin: '120px' },
);
observer.observe(node);
return () => observer.disconnect();
}, [mode, hasMore, isLoading]);
const count = loaded != null && total != null ? `Showing ${loaded} of ${total}` : null;
if (mode === 'infinite') {
return (
<div className='flex min-h-9 flex-col items-center justify-center gap-2 py-4 text-muted-foreground text-sm'>
{hasMore ? (
<>
<div ref={sentinelRef} aria-hidden className='h-px w-full' />
{isLoading && <Loader2 className='size-4 animate-spin' />}
</>
) : (
<span>All caught up</span>
)}
</div>
);
}
return (
<div className='flex flex-col items-center gap-2 py-4'>
{hasMore ? (
<Button variant='outline' size='sm' onClick={onLoadMore} disabled={isLoading}>
{isLoading && <Loader2 className='mr-2 size-4 animate-spin' />}
Load more
</Button>
) : (
<span className='text-muted-foreground text-sm'>All caught up</span>
)}
{count && <span className='text-muted-foreground text-xs'>{count}</span>}
</div>
);
}
type FilterField = { id: string; label: string; variant: FilterVariant; options?: DataTableFilterOption[] };
type SortField = { id: string; label: string };
function getFilterableFields<TData>(table: Table<TData>): FilterField[] {
return table
.getAllColumns()
.filter((column) => column.getCanFilter() && column.columnDef.meta?.variant)
.map((column) => ({
id: column.id,
label: column.columnDef.meta?.label ?? column.id,
variant: column.columnDef.meta?.variant as FilterVariant,
options: column.columnDef.meta?.options,
}));
}
function getSortableFields<TData>(table: Table<TData>): SortField[] {
return table
.getAllColumns()
.filter((column) => column.getCanSort())
.map((column) => ({ id: column.id, label: column.columnDef.meta?.label ?? column.id }));
}
function defaultOperatorFor(variant: FilterVariant): FilterOperator {
return OPERATORS_BY_VARIANT[variant][0];
}
function defaultValueFor(variant: FilterVariant): unknown {
return MULTI_VALUE_OPERATORS.includes(defaultOperatorFor(variant)) ? [] : '';
}
function useReorderSensors() {
return useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
}
function SortableRow({ id, children }: { id: string; children: React.ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition };
return (
<div ref={setNodeRef} style={style} className={cn('flex items-center gap-2', isDragging && 'opacity-60')}>
<button
type='button'
className='shrink-0 cursor-grab touch-none text-muted-foreground/50 active:cursor-grabbing'
aria-label='Reorder'
{...attributes}
{...listeners}
>
<GripVertical className='size-4' />
</button>
{children}
</div>
);
}
function FieldSelect({
fields,
value,
onChange,
}: {
fields: { id: string; label: string }[];
value: string;
onChange: (value: string) => void;
}) {
return (
<Select value={value} onValueChange={(next: string | null) => next !== null && onChange(next)}>
<SelectTrigger className='h-8 w-[120px] shrink-0'>
<SelectValue>{fields.find((field) => field.id === value)?.label ?? value}</SelectValue>
</SelectTrigger>
<SelectContent>
{fields.map((field) => (
<SelectItem key={field.id} value={field.id}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function OperatorSelect({
variant,
value,
onChange,
}: {
variant: FilterVariant;
value: FilterOperator;
onChange: (value: FilterOperator) => void;
}) {
return (
<Select value={value} onValueChange={(next: string | null) => next !== null && onChange(next as FilterOperator)}>
<SelectTrigger className='h-8 w-[136px] shrink-0'>
<SelectValue>{OPERATOR_LABELS[value] ?? value}</SelectValue>
</SelectTrigger>
<SelectContent>
{OPERATORS_BY_VARIANT[variant].map((operator) => (
<SelectItem key={operator} value={operator}>
{OPERATOR_LABELS[operator]}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function MultiSelectValue({
options,
selected,
onChange,
}: {
options: DataTableFilterOption[];
selected: string[];
onChange: (value: string[]) => void;
}) {
const toggle = (value: string) =>
onChange(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value]);
const label =
selected.length === 0
? 'Select…'
: selected.length === 1
? (options.find((option) => option.value === selected[0])?.label ?? selected[0])
: `${selected.length} selected`;
return (
<Popover>
<PopoverTrigger
render={<Button variant='outline' size='sm' className='h-8 flex-1 justify-between font-normal' />}
>
<span className='truncate'>{label}</span>
<ChevronDown className='ml-1 size-3.5 shrink-0 text-muted-foreground' />
</PopoverTrigger>
<PopoverContent align='start' className='w-48 p-1'>
<div className='flex flex-col gap-0.5'>
{options.map((option) => (
<button
key={option.value}
type='button'
onClick={() => toggle(option.value)}
className='flex items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent'
>
<Checkbox checked={selected.includes(option.value)} className='pointer-events-none' />
<span>{option.label}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
);
}
function FilterValueControl({
field,
condition,
onChange,
}: {
field: FilterField;
condition: FilterCondition;
onChange: (value: unknown) => void;
}) {
if (!operatorTakesValue(condition.operator)) {
return <div className='h-8 flex-1 rounded-md border border-dashed bg-muted/30' aria-hidden />;
}
if ((field.variant === 'select' || field.variant === 'multiSelect') && field.options) {
if (MULTI_VALUE_OPERATORS.includes(condition.operator)) {
const selected = Array.isArray(condition.value) ? (condition.value as string[]) : [];
return <MultiSelectValue options={field.options} selected={selected} onChange={onChange} />;
}
const selectedValue = (condition.value as string) ?? '';
return (
<Select value={selectedValue} onValueChange={(next: string | null) => onChange(next ?? '')}>
<SelectTrigger className='h-8 flex-1'>
<SelectValue placeholder='Select…'>
{field.options.find((option) => option.value === selectedValue)?.label ?? (selectedValue || undefined)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
value={(condition.value as string) ?? ''}
onChange={(event) => onChange(event.target.value)}
type={field.variant === 'number' ? 'number' : field.variant === 'date' ? 'date' : 'text'}
placeholder='Value'
className='h-8 flex-1'
/>
);
}
function FilterBuilder<TData>({ table }: { table: Table<TData> }) {
const fields = getFilterableFields(table);
const columnFilters = table.getState().columnFilters;
const rows = columnFilters
.map((columnFilter) => {
const field = fields.find((candidate) => candidate.id === columnFilter.id);
if (!field) return null;
const condition = isFilterCondition(columnFilter.value)
? columnFilter.value
: { operator: defaultOperatorFor(field.variant), value: columnFilter.value };
return { field, condition };
})
.filter((row): row is { field: FilterField; condition: FilterCondition } => row !== null);
const setCondition = (columnId: string, condition: FilterCondition) => {
table.setColumnFilters((current) => [
...current.filter((item) => item.id !== columnId),
{ id: columnId, value: condition },
]);
};
const removeCondition = (columnId: string) =>
table.setColumnFilters((current) => current.filter((item) => item.id !== columnId));
const changeField = (oldId: string, newId: string) => {
const field = fields.find((candidate) => candidate.id === newId);
if (!field) return;
table.setColumnFilters((current) => [
...current.filter((item) => item.id !== oldId && item.id !== newId),
{ id: newId, value: { operator: defaultOperatorFor(field.variant), value: defaultValueFor(field.variant) } },
]);
};
const changeOperator = (field: FilterField, previous: FilterCondition, operator: FilterOperator) => {
const wasMulti = Array.isArray(previous.value);
const willMulti = MULTI_VALUE_OPERATORS.includes(operator);
const value = willMulti ? (wasMulti ? previous.value : []) : wasMulti ? '' : previous.value;
setCondition(field.id, { operator, value });
};
const addCondition = () => {
const used = new Set(columnFilters.map((item) => item.id));
const next = fields.find((field) => !used.has(field.id));
if (!next) return;
setCondition(next.id, { operator: defaultOperatorFor(next.variant), value: defaultValueFor(next.variant) });
};
const allUsed = fields.length > 0 && fields.every((field) => columnFilters.some((item) => item.id === field.id));
const sensors = useReorderSensors();
const dndId = React.useId();
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
table.setColumnFilters((current) => {
const oldIndex = current.findIndex((item) => item.id === active.id);
const newIndex = current.findIndex((item) => item.id === over.id);
if (oldIndex < 0 || newIndex < 0) return current;
return arrayMove(current, oldIndex, newIndex);
});
};
return (
<div className='flex flex-col gap-2'>
{rows.length === 0 ? (
<p className='px-1 py-2 text-muted-foreground text-sm'>No filters applied to this view.</p>
) : (
<DndContext id={dndId} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={rows.map((row) => row.field.id)} strategy={verticalListSortingStrategy}>
<div className='flex flex-col gap-2'>
{rows.map((row, index) => (
<SortableRow key={row.field.id} id={row.field.id}>
<span className='w-12 shrink-0 text-muted-foreground text-sm'>{index === 0 ? 'Where' : 'And'}</span>
<FieldSelect
fields={fields}
value={row.field.id}
onChange={(value) => changeField(row.field.id, value)}
/>
<OperatorSelect
variant={row.field.variant}
value={row.condition.operator}
onChange={(operator) => changeOperator(row.field, row.condition, operator)}
/>
<FilterValueControl
field={row.field}
condition={row.condition}
onChange={(value) => setCondition(row.field.id, { ...row.condition, value })}
/>
<Button
variant='ghost'
size='icon-sm'
className='shrink-0 text-muted-foreground'
onClick={() => removeCondition(row.field.id)}
aria-label='Remove filter'
>
<X className='size-4' />
</Button>
</SortableRow>
))}
</div>
</SortableContext>
</DndContext>
)}
<div className='flex items-center justify-between pt-1'>
<Button
variant='ghost'
size='sm'
className='h-8 px-2 text-muted-foreground'
onClick={addCondition}
disabled={allUsed}
>
<Plus className='mr-1.5 size-4' /> Add filter
</Button>
{rows.length > 0 && (
<Button
variant='ghost'
size='sm'
className='h-8 px-2 text-muted-foreground'
onClick={() => table.setColumnFilters([])}
>
<Trash2 className='mr-1.5 size-4' /> Clear
</Button>
)}
</div>
</div>
);
}
export type DataTableFilterMenuProps<TData> = {
table: Table<TData>;
variant?: 'popover' | 'dialog';
};
function FilterMenuButton({
count,
showChevron = false,
...props
}: React.ComponentProps<typeof Button> & { count: number; showChevron?: boolean }) {
return (
<Button variant='outline' size='sm' className='h-8 border-dashed' {...props}>
<ListFilter className='mr-2 size-4' /> Filter
{count > 0 && <span className='ml-2 rounded-sm bg-secondary px-1.5 font-normal text-xs'>{count}</span>}
{showChevron && <ChevronDown className='ml-1 size-3.5 text-muted-foreground' />}
</Button>
);
}
export function DataTableFilterMenu<TData>({ table, variant = 'popover' }: DataTableFilterMenuProps<TData>) {
const count = table.getState().columnFilters.length;
if (variant === 'dialog') {
return (
<Dialog>
<DialogTrigger render={<FilterMenuButton count={count} />} />
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>Filters</DialogTitle>
<DialogDescription>Show rows that match all of these conditions.</DialogDescription>
</DialogHeader>
<FilterBuilder table={table} />
<DialogFooter>
<Button variant='ghost' size='sm' onClick={() => table.setColumnFilters([])}>
Clear all
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Popover>
<PopoverTrigger render={<FilterMenuButton count={count} showChevron />} />
<PopoverContent align='start' className='w-[560px] p-2'>
<FilterBuilder table={table} />
</PopoverContent>
</Popover>
);
}
function SortBuilder<TData>({ table }: { table: Table<TData> }) {
const fields = getSortableFields(table);
const sorting = table.getState().sorting;
const setDir = (id: string, desc: boolean) =>
table.setSorting((current) => current.map((sort) => (sort.id === id ? { ...sort, desc } : sort)));
const remove = (id: string) => table.setSorting((current) => current.filter((sort) => sort.id !== id));
const changeField = (oldId: string, newId: string) =>
table.setSorting((current) => [
...current.filter((sort) => sort.id !== oldId && sort.id !== newId),
{ id: newId, desc: false },
]);
const add = () => {
const used = new Set(sorting.map((sort) => sort.id));
const next = fields.find((field) => !used.has(field.id));
if (next) table.setSorting((current) => [...current, { id: next.id, desc: false }]);
};
const allUsed = fields.length > 0 && fields.every((field) => sorting.some((sort) => sort.id === field.id));
const sensors = useReorderSensors();
const dndId = React.useId();
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
table.setSorting((current) => {
const oldIndex = current.findIndex((sort) => sort.id === active.id);
const newIndex = current.findIndex((sort) => sort.id === over.id);
if (oldIndex < 0 || newIndex < 0) return current;
return arrayMove(current, oldIndex, newIndex);
});
};
return (
<div className='flex flex-col gap-2'>
{sorting.length === 0 ? (
<p className='px-1 py-2 text-muted-foreground text-sm'>No sorts applied to this view.</p>
) : (
<DndContext id={dndId} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sorting.map((sort) => sort.id)} strategy={verticalListSortingStrategy}>
<div className='flex flex-col gap-2'>
{sorting.map((sort, index) => (
<SortableRow key={sort.id} id={sort.id}>
<span className='w-12 shrink-0 text-muted-foreground text-sm'>{index === 0 ? 'Sort' : 'Then'}</span>
<FieldSelect fields={fields} value={sort.id} onChange={(value) => changeField(sort.id, value)} />
<Select
value={sort.desc ? 'desc' : 'asc'}
onValueChange={(value: string | null) => value !== null && setDir(sort.id, value === 'desc')}
>
<SelectTrigger className='h-8 flex-1'>
<SelectValue>{sort.desc ? 'Descending' : 'Ascending'}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value='asc'>Ascending</SelectItem>
<SelectItem value='desc'>Descending</SelectItem>
</SelectContent>
</Select>
<Button
variant='ghost'
size='icon-sm'
className='shrink-0 text-muted-foreground'
onClick={() => remove(sort.id)}
aria-label='Remove sort'
>
<X className='size-4' />
</Button>
</SortableRow>
))}
</div>
</SortableContext>
</DndContext>
)}
<div className='pt-1'>
<Button variant='ghost' size='sm' className='h-8 px-2 text-muted-foreground' onClick={add} disabled={allUsed}>
<Plus className='mr-1.5 size-4' /> Add sort
</Button>
</div>
</div>
);
}
export function DataTableSortMenu<TData>({ table }: { table: Table<TData> }) {
const count = table.getState().sorting.length;
return (
<Popover>
<PopoverTrigger render={<Button variant='outline' size='sm' className='h-8 border-dashed' />}>
<ArrowDownUp className='mr-2 size-4' /> Sort
{count > 0 && <span className='ml-2 rounded-sm bg-secondary px-1.5 font-normal text-xs'>{count}</span>}
<ChevronDown className='ml-1 size-3.5 text-muted-foreground' />
</PopoverTrigger>
<PopoverContent align='start' className='w-[420px] p-2'>
<SortBuilder table={table} />
</PopoverContent>
</Popover>
);
}
export type DataTableView = { name: string; filters: ColumnFiltersState; sorting: SortingState };
export type DataTableViewsProps<TData> = {
table: Table<TData>;
storageKey: string;
};
export function DataTableViews<TData>({ table, storageKey }: DataTableViewsProps<TData>) {
const key = `shuip:dt-views:${storageKey}`;
const [views, setViews] = React.useState<DataTableView[]>([]);
const [name, setName] = React.useState('');
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
try {
const raw = localStorage.getItem(key);
if (raw) setViews(JSON.parse(raw) as DataTableView[]);
} catch {
setViews([]);
}
}, [key]);
const persist = (next: DataTableView[]) => {
setViews(next);
try {
localStorage.setItem(key, JSON.stringify(next));
} catch {
/* storage unavailable */
}
};
const save = () => {
const trimmed = name.trim();
if (!trimmed) return;
const view: DataTableView = {
name: trimmed,
filters: table.getState().columnFilters,
sorting: table.getState().sorting,
};
persist([...views.filter((current) => current.name !== trimmed), view]);
setName('');
setSaving(false);
};
const apply = (view: DataTableView) => {
table.setColumnFilters(view.filters);
table.setSorting(view.sorting);
};
const remove = (viewName: string) => persist(views.filter((current) => current.name !== viewName));
return (
<Popover onOpenChange={(open) => !open && setSaving(false)}>
<PopoverTrigger render={<Button variant='outline' size='sm' className='h-8' />}>
<Bookmark className='mr-2 size-4' /> Views
{views.length > 0 && (
<span className='ml-2 rounded-sm bg-secondary px-1.5 font-normal text-xs'>{views.length}</span>
)}
<ChevronDown className='ml-1 size-3.5 text-muted-foreground' />
</PopoverTrigger>
<PopoverContent align='end' className='w-60 p-1'>
<div className='flex flex-col gap-0.5'>
{views.length === 0 ? (
<p className='px-2 py-1.5 text-muted-foreground text-sm'>No saved views.</p>
) : (
views.map((view) => (
<div key={view.name} className='flex items-center'>
<button
type='button'
onClick={() => apply(view)}
className='flex-1 truncate rounded-sm px-2 py-1.5 text-left text-sm hover:bg-accent'
>
{view.name}
</button>
<Button
variant='ghost'
size='icon-sm'
className='shrink-0 text-muted-foreground'
onClick={() => remove(view.name)}
aria-label={`Delete ${view.name}`}
>
<Trash2 className='size-3.5' />
</Button>
</div>
))
)}
</div>
<Separator className='my-1' />
{saving ? (
<div className='flex items-center gap-1.5 p-1'>
<Input
autoFocus
value={name}
onChange={(event) => setName(event.target.value)}
onKeyDown={(event) => event.key === 'Enter' && save()}
placeholder='View name'
className='h-8'
/>
<Button size='sm' className='h-8' onClick={save} disabled={!name.trim()}>
Save
</Button>
</div>
) : (
<Button variant='ghost' size='sm' className='w-full justify-start' onClick={() => setSaving(true)}>
<Plus className='mr-2 size-4' /> Save current view
</Button>
)}
</PopoverContent>
</Popover>
);
}
The command is the same for Base UI and Radix: the CLI reads the style in your components.json and installs the matching variant.
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) => (
<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
'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
'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} />
<DataTableLoadMore
mode={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) => (
<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
'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 }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
aria-label='Select all'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
aria-label='Select row'
/>
),
enableSorting: false,
enableHiding: false,
size: 48,
},
{
accessorKey: 'name',
size: 220,
header: ({ column }) => <DataTableColumnHeader column={column} title='Name' />,
meta: { label: 'Name' },
cell: ({ row }) => (
<div className='flex items-center gap-2'>
<Avatar name={row.original.name} />
<span className='font-medium'>{row.original.name}</span>
</div>
),
},
{
accessorKey: 'email',
size: 240,
header: ({ column }) => <DataTableColumnHeader column={column} title='Email' />,
meta: { label: 'Email' },
cell: ({ row }) => <span className='text-muted-foreground'>{row.original.email}</span>,
},
{
accessorKey: 'team',
size: 150,
header: ({ column }) => <DataTableColumnHeader column={column} title='Team' />,
meta: { label: 'Team' },
},
{
accessorKey: 'stage',
size: 150,
header: ({ column }) => <DataTableColumnHeader column={column} title='Stage' />,
meta: { label: 'Stage', variant: 'multiSelect', options: stageOptions },
filterFn: 'arrIncludesSome',
cell: ({ row }) => (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium text-xs capitalize ${stageStyles[row.original.stage]}`}
>
{row.original.stage}
</span>
),
},
{
accessorKey: 'tags',
size: 180,
header: 'Tags',
enableSorting: false,
meta: { label: 'Tags' },
cell: ({ row }) => (
<div className='flex flex-wrap gap-1'>
{row.original.tags.map((tag) => (
<span key={tag} className='rounded border bg-muted px-1.5 py-0.5 text-muted-foreground text-xs'>
{tag}
</span>
))}
</div>
),
},
],
[],
);
const { table } = useDataTable({
data: people,
columns,
getRowId: (row) => row.id,
enableRowSelection: true,
enableColumnPinning: true,
initialState: { columnPinning: { left: ['select', 'name'], right: [] } },
});
return (
<div className='flex w-full min-w-0 flex-col gap-3'>
<DataTableToolbar table={table} searchPlaceholder='Search people...' />
<DataTable table={table} />
<DataTablePagination table={table} />
</div>
);
}
Server
'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