Shadcn Table
componentdata-grid-select-columnsource: ok · hit
Data Grid Select Column
A reusable select column definition for the data grid with checkbox selection and shift-click support
npx shadcn add "https://tablecn.com/r/data-grid-select-column.json"free
Interaction
dropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@tanstack/react-table
Registry dependencies
checkbox
Files3 · source cached 10/10/2026, 12:19:26 AM
src/registry/bases/base/components/data-grid/data-grid-select-column.tsx
"use client";
import type {
CellContext,
ColumnDef,
HeaderContext,
RowData,
} from "@tanstack/react-table";
import { cn } from "cn";
import * as React from "react";
import type { DataGridFeatures } from "@/lib/data-grid-features";
import { Checkbox } from "@/registry/bases/base/ui/checkbox";
type HitboxSize = "default" | "sm" | "lg";
interface DataGridSelectHitboxProps {
htmlFor: string;
children: React.ReactNode;
size?: HitboxSize;
debug?: boolean;
}
function DataGridSelectHitbox({
htmlFor,
children,
size,
debug,
}: DataGridSelectHitboxProps) {
return (
<div
className={cn(
"group relative -my-1.5 h-[calc(100%+0.75rem)] py-1.5",
size === "default" && "-ms-3 -me-2 ps-3 pe-2",
size === "sm" && "-ms-3 -me-1.5 ps-3 pe-1.5",
size === "lg" && "-mx-3 px-3",
)}
>
{children}
<label
htmlFor={htmlFor}
className={cn(
"absolute inset-0 cursor-pointer",
debug && "border border-dashed border-red-500 bg-red-500/20",
)}
/>
</div>
);
}
interface DataGridSelectCheckboxProps extends Omit<
React.ComponentProps<typeof Checkbox>,
"id"
> {
rowNumber?: number;
hitboxSize?: HitboxSize;
debug?: boolean;
}
function DataGridSelectCheckbox({
rowNumber,
hitboxSize,
debug,
checked,
className,
...props
}: DataGridSelectCheckboxProps) {
const id = React.useId();
if (rowNumber !== undefined) {
return (
<DataGridSelectHitbox htmlFor={id} size={hitboxSize} debug={debug}>
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-s-3 top-1.5 flex size-4 translate-y-0.5 items-center justify-center text-xs text-muted-foreground tabular-nums transition-opacity group-hover:opacity-0 group-has-focus-visible:opacity-0",
checked && "opacity-0",
)}
>
{rowNumber}
</div>
<Checkbox
id={id}
tabIndex={-1}
className={cn(
"relative translate-y-0.5 transition-[shadow,border,opacity] hover:border-primary/40",
"opacity-0 group-hover:opacity-100 focus-visible:opacity-100 data-checked:opacity-100",
className,
)}
checked={checked}
{...props}
/>
</DataGridSelectHitbox>
);
}
return (
<DataGridSelectHitbox htmlFor={id} size={hitboxSize} debug={debug}>
<Checkbox
id={id}
tabIndex={-1}
className={cn(
"relative translate-y-0.5 transition-[shadow,border] hover:border-primary/40",
className,
)}
checked={checked}
{...props}
/>
</DataGridSelectHitbox>
);
}
interface DataGridSelectHeaderProps<TData extends RowData> extends Pick<
HeaderContext<DataGridFeatures, TData>,
"table"
> {
hitboxSize?: HitboxSize;
readOnly?: boolean;
debug?: boolean;
}
function DataGridSelectHeader<TData extends RowData>({
table,
hitboxSize,
readOnly,
debug,
}: DataGridSelectHeaderProps<TData>) {
const onCheckedChange = React.useCallback(
(value: boolean) => table.toggleAllPageRowsSelected(value),
[table],
);
if (readOnly) {
return (
<div className="mt-1 flex items-center ps-1 text-sm text-muted-foreground">
#
</div>
);
}
return (
<DataGridSelectCheckbox
aria-label="Select all"
checked={table.getIsAllPageRowsSelected()}
indeterminate={
table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()
}
onCheckedChange={onCheckedChange}
hitboxSize={hitboxSize}
debug={debug}
/>
);
}
interface DataGridSelectCellProps<TData extends RowData> extends Pick<
CellContext<DataGridFeatures, TData>,
"row" | "table"
> {
hitboxSize?: HitboxSize;
enableRowMarkers?: boolean;
readOnly?: boolean;
debug?: boolean;
}
function DataGridSelectCell<TData extends RowData>({
row,
table,
hitboxSize,
enableRowMarkers,
readOnly,
debug,
}: DataGridSelectCellProps<TData>) {
const meta = table.options.meta;
const rowNumber = enableRowMarkers ? row.getDisplayIndex() + 1 : undefined;
const onCheckedChange = React.useCallback(
(value: boolean) => {
if (meta?.onRowSelect) {
meta.onRowSelect(row.id, value, false);
} else {
row.toggleSelected(value);
}
},
[meta, row],
);
const onClick = React.useCallback<
NonNullable<React.ComponentProps<typeof Checkbox>["onClick"]>
>(
(event) => {
if (event.shiftKey) {
event.preventDefault();
meta?.onRowSelect?.(row.id, !row.getIsSelected(), true);
}
},
[meta, row],
);
if (readOnly) {
return (
<div className="flex items-center ps-1 text-xs text-muted-foreground tabular-nums">
{rowNumber ?? row.index + 1}
</div>
);
}
return (
<DataGridSelectCheckbox
aria-label={rowNumber ? `Select row ${rowNumber}` : "Select row"}
checked={row.getIsSelected()}
onCheckedChange={onCheckedChange}
onClick={onClick}
rowNumber={rowNumber}
hitboxSize={hitboxSize}
debug={debug}
/>
);
}
interface GetDataGridSelectColumnOptions<TData extends RowData> extends Omit<
Partial<ColumnDef<DataGridFeatures, TData>>,
"id" | "header" | "cell"
> {
enableRowMarkers?: boolean;
readOnly?: boolean;
hitboxSize?: HitboxSize;
debug?: boolean;
}
export function getDataGridSelectColumn<TData extends RowData>({
size = 40,
hitboxSize = "default",
enableHiding = false,
enableResizing = false,
enableSorting = false,
enableRowMarkers = false,
readOnly = false,
debug = false,
...props
}: GetDataGridSelectColumnOptions<TData> = {}): ColumnDef<
DataGridFeatures,
TData
> {
return {
id: "select",
header: ({ table }) => (
<DataGridSelectHeader
table={table}
hitboxSize={hitboxSize}
readOnly={readOnly}
debug={debug}
/>
),
cell: ({ row, table }) => (
<DataGridSelectCell
row={row}
table={table}
enableRowMarkers={enableRowMarkers}
readOnly={readOnly}
hitboxSize={hitboxSize}
debug={debug}
/>
),
size,
enableHiding,
enableResizing,
enableSorting,
...props,
};
}
src/lib/data-grid-features.ts
import {
assignPrototypeAPIs,
assignTableAPIs,
type CellData,
columnFilteringFeature,
columnOrderingFeature,
columnPinningFeature,
columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
createFilteredRowModel,
createSortedRowModel,
functionalUpdate,
makeStateUpdater,
metaHelper,
type OnChangeFn,
type RowData,
rowSelectionFeature,
rowSortingFeature,
type Table,
type TableFeature,
type TableFeatures,
tableFeatures,
type Updater,
} from "@tanstack/react-table";
import type {
CellPosition,
DataGridColumnMeta,
DataGridTableMeta,
RowHeightValue,
} from "@/lib/data-grid-types";
import { getLineCount, getRowHeightValue } from "@/lib/data-grid-utils";
const DEFAULT_ROW_HEIGHT: RowHeightValue = "short";
interface TableState_DataGridRowHeight {
rowHeight: RowHeightValue;
}
interface TableOptions_DataGridRowHeight {
onRowHeightChange?: OnChangeFn<RowHeightValue>;
}
interface Table_DataGridRowHeight {
getRowHeight: () => RowHeightValue;
getRowSize: () => number;
getRowLineCount: () => number;
setRowHeight: (updater: Updater<RowHeightValue>) => void;
resetRowHeight: (defaultState?: boolean) => void;
}
interface TableState_DataGridCellEditing {
editingCell: CellPosition | null;
}
interface TableOptions_DataGridCellEditing {
enableCellEditing?: boolean;
onEditingCellChange?: OnChangeFn<CellPosition | null>;
}
interface ColumnDef_DataGridCellEditing {
enableCellEditing?: boolean;
}
interface Table_DataGridCellEditing {
getEditingCell: () => CellPosition | null;
setEditingCell: (updater: Updater<CellPosition | null>) => void;
resetEditingCell: (defaultState?: boolean) => void;
}
interface Column_DataGridCellEditing {
getCanEdit: () => boolean;
}
interface Cell_DataGridCellEditing {
getCanEdit: () => boolean;
getIsEditing: () => boolean;
}
declare module "@tanstack/react-table" {
interface Plugins {
dataGridRowHeightFeature: TableFeature;
dataGridCellEditingFeature: TableFeature;
}
interface TableState_FeatureMap {
dataGridRowHeightFeature: TableState_DataGridRowHeight;
dataGridCellEditingFeature: TableState_DataGridCellEditing;
}
interface TableOptions_FeatureMap<
in out TFeatures extends TableFeatures,
in out TData extends RowData,
> {
dataGridRowHeightFeature: TableOptions_DataGridRowHeight;
dataGridCellEditingFeature: TableOptions_DataGridCellEditing;
}
interface ColumnDef_FeatureMap<
in out TFeatures extends TableFeatures,
in out TData extends RowData,
TValue extends CellData,
> {
dataGridCellEditingFeature: ColumnDef_DataGridCellEditing;
}
interface Table_FeatureMap<
in out TFeatures extends TableFeatures,
in out TData extends RowData,
> {
dataGridRowHeightFeature: Table_DataGridRowHeight;
dataGridCellEditingFeature: Table_DataGridCellEditing;
}
interface Column_FeatureMap<
in out TFeatures extends TableFeatures,
in out TData extends RowData,
> {
dataGridCellEditingFeature: Column_DataGridCellEditing;
}
interface Cell_FeatureMap {
dataGridCellEditingFeature: Cell_DataGridCellEditing;
}
}
type DataGridInstance = Table<DataGridFeatures, RowData>;
function asDataGrid(table: object) {
return table as DataGridInstance;
}
const dataGridRowHeightFeature: TableFeature = {
getInitialState: (initialState) => ({
rowHeight: DEFAULT_ROW_HEIGHT,
...initialState,
}),
getDefaultTableOptions: (table) => {
const options: TableOptions_DataGridRowHeight = {
onRowHeightChange: makeStateUpdater("rowHeight", table),
};
return options;
},
constructTableAPIs: (table) => {
const instance = asDataGrid(table);
const setRowHeight = (updater: Updater<RowHeightValue>) =>
instance.options.onRowHeightChange?.((old) =>
functionalUpdate(updater, old),
);
assignTableAPIs("dataGridRowHeightFeature", table, {
table_getRowHeight: {
fn: () => instance.atoms.rowHeight.get(),
},
table_getRowSize: {
fn: () => getRowHeightValue(instance.atoms.rowHeight.get()),
},
table_getRowLineCount: {
fn: () => getLineCount(instance.atoms.rowHeight.get()),
},
table_setRowHeight: { fn: setRowHeight },
table_resetRowHeight: {
fn: (defaultState?: boolean) =>
setRowHeight(
defaultState
? DEFAULT_ROW_HEIGHT
: (instance.initialState.rowHeight ?? DEFAULT_ROW_HEIGHT),
),
},
});
},
};
function getCanEditColumn(column: {
table: object;
columnDef: ColumnDef_DataGridCellEditing;
}) {
return (
asDataGrid(column.table).options.enableCellEditing !== false &&
column.columnDef.enableCellEditing !== false
);
}
const dataGridCellEditingFeature: TableFeature = {
getInitialState: (initialState) => ({
editingCell: null,
...initialState,
}),
getDefaultTableOptions: (table) => {
const options: TableOptions_DataGridCellEditing = {
onEditingCellChange: makeStateUpdater("editingCell", table),
};
return options;
},
constructTableAPIs: (table) => {
const instance = asDataGrid(table);
const setEditingCell = (updater: Updater<CellPosition | null>) =>
instance.options.onEditingCellChange?.((old) =>
functionalUpdate(updater, old),
);
assignTableAPIs("dataGridCellEditingFeature", table, {
table_getEditingCell: {
fn: () => instance.atoms.editingCell.get(),
},
table_setEditingCell: { fn: setEditingCell },
table_resetEditingCell: {
fn: (defaultState?: boolean) =>
setEditingCell(
defaultState ? null : (instance.initialState.editingCell ?? null),
),
},
});
},
assignColumnPrototype: (prototype, table) => {
assignPrototypeAPIs("dataGridCellEditingFeature", prototype, table, {
column_getCanEdit: { fn: getCanEditColumn },
});
},
assignCellPrototype: (prototype, table) => {
assignPrototypeAPIs("dataGridCellEditingFeature", prototype, table, {
cell_getCanEdit: {
fn: (cell: { column: Parameters<typeof getCanEditColumn>[0] }) =>
getCanEditColumn(cell.column),
},
cell_getIsEditing: {
fn: (cell: {
table: object;
row: { id: string };
column: { id: string };
}) => {
const editingCell = asDataGrid(cell.table).atoms.editingCell.get();
return (
editingCell?.rowId === cell.row.id &&
editingCell.columnId === cell.column.id
);
},
},
});
},
};
export const dataGridFeatures = tableFeatures({
columnFilteringFeature,
columnOrderingFeature,
columnPinningFeature,
columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
rowSelectionFeature,
rowSortingFeature,
dataGridRowHeightFeature,
dataGridCellEditingFeature,
filteredRowModel: createFilteredRowModel(),
sortedRowModel: createSortedRowModel(),
tableMeta: metaHelper<DataGridTableMeta>(),
columnMeta: metaHelper<DataGridColumnMeta>(),
});
export type DataGridFeatures = typeof dataGridFeatures;
src/lib/data-grid-types.ts
import type { Cell, RowData } from "@tanstack/react-table";
import type { DataGridFeatures } from "@/lib/data-grid-features";
export type Direction = "ltr" | "rtl";
export type RowHeightValue = "short" | "medium" | "tall" | "extra-tall";
export interface CellSelectOption {
label: string;
value: string;
icon?: React.ComponentType<React.ComponentProps<"svg">>;
count?: number;
}
export type CellOpts =
| {
variant: "short-text";
}
| {
variant: "long-text";
}
| {
variant: "number";
min?: number;
max?: number;
step?: number;
}
| {
variant: "select";
options: CellSelectOption[];
}
| {
variant: "multi-select";
options: CellSelectOption[];
}
| {
variant: "checkbox";
}
| {
variant: "date";
}
| {
variant: "url";
}
| {
variant: "file";
maxFileSize?: number;
maxFiles?: number;
accept?: string;
multiple?: boolean;
};
export interface CellUpdate {
rowId: string;
columnId: string;
value: unknown;
}
export interface DataGridColumnMeta {
label?: string;
cell?: CellOpts;
}
export interface DataGridTableMeta {
dataGridRef?: React.RefObject<HTMLElement | null>;
cellMapRef?: React.RefObject<Map<string, HTMLDivElement>>;
focusedCell?: CellPosition | null;
editingCell?: CellPosition | null;
selectionState?: SelectionState;
searchOpen?: boolean;
getIsCellSelected?: (rowId: string, columnId: string) => boolean;
getIsSearchMatch?: (rowId: string, columnId: string) => boolean;
getIsActiveSearchMatch?: (rowId: string, columnId: string) => boolean;
scrollToCell?: (
rowId: string,
columnId: string,
align?: "auto" | "start" | "center" | "end",
) => void;
onRowSelect?: (rowId: string, checked: boolean, shiftKey: boolean) => void;
onDataUpdate?: (params: CellUpdate | Array<CellUpdate>) => void;
onRowsDelete?: (rowIds: string[]) => void | Promise<void>;
onColumnClick?: (columnId: string) => void;
onCellClick?: (
rowId: string,
columnId: string,
event?: React.MouseEvent,
) => void;
onCellDoubleClick?: (rowId: string, columnId: string) => void;
onCellMouseDown?: (
rowId: string,
columnId: string,
event: React.MouseEvent,
) => void;
onCellMouseEnter?: (rowId: string, columnId: string) => void;
onCellMouseUp?: () => void;
onCellContextMenu?: (
rowId: string,
columnId: string,
event: React.MouseEvent,
) => void;
onCellEditingStart?: (rowId: string, columnId: string) => void;
onCellEditingStop?: (opts?: {
direction?: NavigationDirection;
moveToNextRow?: boolean;
}) => void;
onCellsCopy?: () => void;
onCellsCut?: () => void;
onCellsPaste?: (expand?: boolean) => void;
onSelectionClear?: () => void;
onFilesUpload?: (params: {
files: File[];
rowId: string;
columnId: string;
}) => Promise<FileCellData[]>;
onFilesDelete?: (params: {
fileIds: string[];
rowId: string;
columnId: string;
}) => void | Promise<void>;
contextMenu?: ContextMenuState;
onContextMenuOpenChange?: (open: boolean) => void;
pasteDialog?: PasteDialogState;
onPasteDialogOpenChange?: (open: boolean) => void;
readOnly?: boolean;
}
export interface CellPosition {
rowId: string;
columnId: string;
}
export interface CellRange {
start: CellPosition;
end: CellPosition;
}
export interface SelectionState {
selectedCells: Set<string>;
selectionRange: CellRange | null;
isSelecting: boolean;
}
export interface ContextMenuState {
open: boolean;
x: number;
y: number;
}
export interface PasteDialogState {
open: boolean;
rowsNeeded: number;
clipboardText: string;
}
export type NavigationDirection =
| "up"
| "down"
| "left"
| "right"
| "home"
| "end"
| "ctrl+up"
| "ctrl+down"
| "ctrl+home"
| "ctrl+end"
| "pageup"
| "pagedown"
| "pageleft"
| "pageright"
| "tab"
| "shift+tab";
export interface SearchState {
searchMatches: CellPosition[];
matchIndex: number;
searchOpen: boolean;
onSearchOpenChange: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onSearch: (query: string) => void;
onNavigateToNextMatch: () => void;
onNavigateToPrevMatch: () => void;
}
export interface DataGridCellProps<TData extends RowData> {
cell: Cell<DataGridFeatures, TData>;
tableMeta: DataGridTableMeta;
rowId: string;
columnId: string;
rowHeight: RowHeightValue;
isEditing: boolean;
isFocused: boolean;
isSelected: boolean;
isSearchMatch: boolean;
isActiveSearchMatch: boolean;
readOnly: boolean;
}
export interface FileCellData {
id: string;
name: string;
size: number;
type: string;
url?: string;
}
export type TextFilterOperator =
| "contains"
| "notContains"
| "equals"
| "notEquals"
| "startsWith"
| "endsWith"
| "isEmpty"
| "isNotEmpty";
export type NumberFilterOperator =
| "equals"
| "notEquals"
| "lessThan"
| "lessThanOrEqual"
| "greaterThan"
| "greaterThanOrEqual"
| "isBetween"
| "isEmpty"
| "isNotEmpty";
export type DateFilterOperator =
| "equals"
| "notEquals"
| "before"
| "after"
| "onOrBefore"
| "onOrAfter"
| "isBetween"
| "isEmpty"
| "isNotEmpty";
export type SelectFilterOperator =
| "is"
| "isNot"
| "isAnyOf"
| "isNoneOf"
| "isEmpty"
| "isNotEmpty";
export type BooleanFilterOperator = "isTrue" | "isFalse";
export type FilterOperator =
| TextFilterOperator
| NumberFilterOperator
| DateFilterOperator
| SelectFilterOperator
| BooleanFilterOperator;
export interface FilterValue {
operator: FilterOperator;
value?: string | number | string[];
endValue?: string | number;
}