shadimage
niko-table
componentdata-table-column-dndsource: ok · refreshed

DataTable Column DnD

Drag-and-drop column reordering with composable primitives. Includes DnD provider, draggable header, drag-along cell, and column DnD header/body. Does not ship row DnD.

npx shadcn add "https://niko-table.com/r/data-table-column-dnd.json"
free

Where it goes

navbardata-table

Interaction

drag

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@dnd-kit/core@dnd-kit/modifiers@dnd-kit/sortable@dnd-kit/utilities

Registry dependencies

https://niko-table.com/r/data-table-core.json

Files3 · source cached 10/8/2026, 7:36:18 AM

src/components/niko-table/filters/table-column-dnd.tsx
"use client"

/**
 * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
 *
 * Before reporting anything: please check the changelog first.
 *  - In-repo: ./CHANGELOG.md
 *  - Docs site: https://niko-table.com/changelog
 *
 * Found a bug or have a fix? Open an issue or PR on GitHub so other
 * users (and future LLMs reading this code) benefit:
 * https://github.com/Semkoo/niko-table-registry
 */
import React, { type CSSProperties } from "react"
import {
  DndContext,
  KeyboardSensor,
  MouseSensor,
  TouchSensor,
  closestCenter,
  type DragEndEvent,
  type Modifier,
  useSensor,
  useSensors,
} from "@dnd-kit/core"
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"
import {
  arrayMove,
  SortableContext,
  useSortable,
  horizontalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"

import { TableHead, TableCell as TableCellUI } from "@/components/ui/table"
import { cn } from "@/lib/utils"

import type { DataTableCell, DataTableHeader } from "../types"
import type { RowData } from "@tanstack/react-table"
// ============================================================================
// TableColumnDndProvider
// ============================================================================

export interface TableColumnDndProviderProps {
  children: React.ReactNode
  /** The current column order (array of column IDs) */
  columnOrder: string[]
  /** Callback when columns are reordered. Receives the new column order. */
  onColumnOrderChange: (columnOrder: string[]) => void
  /** DnD modifiers. Defaults to [restrictToHorizontalAxis]. */
  modifiers?: Modifier[]
}

export function TableColumnDndProvider({
  children,
  columnOrder,
  onColumnOrderChange,
  modifiers = [restrictToHorizontalAxis],
}: TableColumnDndProviderProps) {
  // Stable across SSR + hydration. Without this, @dnd-kit's module-level
  // `DndDescribedBy-N` counter can diverge (server N=73, client N=1) and
  // React warns on `aria-describedby` mismatches.
  const dndContextId = React.useId()

  // 8px drag threshold so a click on inline header chrome (sort menu,
  // help tooltip trigger) lands as a click, not as a drag start. dnd-kit
  // suppresses the click only after the pointer moves past the threshold.
  const sensors = useSensors(
    useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
    useSensor(TouchSensor, { activationConstraint: { distance: 8 } }),
    useSensor(KeyboardSensor, {}),
  )

  const handleDragEnd = React.useCallback(
    (event: DragEndEvent) => {
      const { active, over } = event
      if (active && over && active.id !== over.id) {
        const oldIndex = columnOrder.indexOf(active.id as string)
        const newIndex = columnOrder.indexOf(over.id as string)
        onColumnOrderChange(arrayMove(columnOrder, oldIndex, newIndex))
      }
    },
    [columnOrder, onColumnOrderChange],
  )

  return (
    <DndContext
      id={dndContextId}
      collisionDetection={closestCenter}
      modifiers={modifiers}
      onDragEnd={handleDragEnd}
      sensors={sensors}
    >
      <SortableContext
        items={columnOrder}
        strategy={horizontalListSortingStrategy}
      >
        {children}
      </SortableContext>
    </DndContext>
  )
}

TableColumnDndProvider.displayName = "TableColumnDndProvider"

// ============================================================================
// TableDraggableHeader
// ============================================================================

export interface TableDraggableHeaderProps<TData extends RowData, TValue> {
  /** The header instance from TanStack Table */
  header: DataTableHeader<TData, TValue>
  children: React.ReactNode
  className?: string
  /** Additional styles merged with DnD transform styles */
  style?: CSSProperties
}

export function TableDraggableHeader<TData extends RowData, TValue>({
  header,
  children,
  className,
  style: externalStyle,
}: TableDraggableHeaderProps<TData, TValue>) {
  const { attributes, isDragging, listeners, setNodeRef, transform } =
    useSortable({
      id: header.column.id,
    })

  const style: CSSProperties = {
    opacity: isDragging ? 0.8 : 1,
    position: "relative",
    transform: CSS.Translate.toString(transform),
    transition: "width transform 0.2s ease-in-out",
    whiteSpace: "nowrap",
    zIndex: isDragging ? 1 : 0,
    cursor: "grab",
    ...externalStyle,
  }

  return (
    <TableHead
      colSpan={header.colSpan}
      ref={setNodeRef}
      style={style}
      className={cn(isDragging && "bg-muted/50", className)}
      {...attributes}
      {...listeners}
    >
      {children}
    </TableHead>
  )
}

TableDraggableHeader.displayName = "TableDraggableHeader"

// ============================================================================
// TableDragAlongCell
// ============================================================================

export interface TableDragAlongCellProps<TData extends RowData, TValue> {
  /** The cell instance from TanStack Table */
  cell: DataTableCell<TData, TValue>
  children: React.ReactNode
  className?: string
  /** Additional styles merged with DnD transform styles */
  style?: CSSProperties
}

export function TableDragAlongCell<TData extends RowData, TValue>({
  cell,
  children,
  className,
  style: externalStyle,
}: TableDragAlongCellProps<TData, TValue>) {
  const { isDragging, setNodeRef, transform } = useSortable({
    id: cell.column.id,
  })

  const style: CSSProperties = {
    opacity: isDragging ? 0.8 : 1,
    position: "relative",
    transform: CSS.Translate.toString(transform),
    transition: "width transform 0.2s ease-in-out",
    zIndex: isDragging ? 1 : 0,
    ...externalStyle,
  }

  return (
    <TableCellUI style={style} ref={setNodeRef} className={className}>
      {children}
    </TableCellUI>
  )
}

TableDragAlongCell.displayName = "TableDragAlongCell"
src/components/niko-table/components/data-table-column-dnd.tsx
"use client"

/**
 * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
 *
 * Before reporting anything: please check the changelog first.
 *  - In-repo: ./CHANGELOG.md
 *  - Docs site: https://niko-table.com/changelog
 *
 * Found a bug or have a fix? Open an issue or PR on GitHub so other
 * users (and future LLMs reading this code) benefit:
 * https://github.com/Semkoo/niko-table-registry
 */
import type { RowData } from "@tanstack/react-table"
import {
  TableColumnDndProvider,
  type TableColumnDndProviderProps,
  TableDraggableHeader,
  type TableDraggableHeaderProps,
  TableDragAlongCell,
  type TableDragAlongCellProps,
} from "../filters/table-column-dnd"

// ============================================================================
// DataTableColumnDndProvider
// ============================================================================

export type DataTableColumnDndProviderProps = Omit<
  TableColumnDndProviderProps,
  never
>

/**
 * Context-aware column DnD provider that wraps children with drag-and-drop
 * column reordering capabilities.
 *
 * @example
 * <DataTableRoot
 *   data={data}
 *   columns={columns}
 *   state={{ columnOrder }}
 *   onColumnOrderChange={setColumnOrder}
 * >
 *   <DataTableColumnDndProvider
 *     columnOrder={columnOrder}
 *     onColumnOrderChange={setColumnOrder}
 *   >
 *     <DataTable>
 *       <DataTableDndHeader />
 *       <DataTableDndColumnBody />
 *     </DataTable>
 *   </DataTableColumnDndProvider>
 * </DataTableRoot>
 */
export function DataTableColumnDndProvider(
  props: DataTableColumnDndProviderProps,
) {
  return <TableColumnDndProvider {...props} />
}

DataTableColumnDndProvider.displayName = "DataTableColumnDndProvider"

// ============================================================================
// DataTableDraggableHeader
// ============================================================================

export type DataTableDraggableHeaderProps<
  TData extends RowData,
  TValue,
> = TableDraggableHeaderProps<TData, TValue>

/**
 * Context-aware draggable header cell for column DnD.
 *
 * @example
 * <DataTableDraggableHeader header={header}>
 *   {flexRender(header.column.columnDef.header, header.getContext())}
 * </DataTableDraggableHeader>
 */
export function DataTableDraggableHeader<TData extends RowData, TValue>(
  props: DataTableDraggableHeaderProps<TData, TValue>,
) {
  return <TableDraggableHeader<TData, TValue> {...props} />
}

DataTableDraggableHeader.displayName = "DataTableDraggableHeader"

// ============================================================================
// DataTableDragAlongCell
// ============================================================================

export type DataTableDragAlongCellProps<
  TData extends RowData,
  TValue,
> = TableDragAlongCellProps<TData, TValue>

/**
 * Context-aware cell that follows column drag position.
 *
 * @example
 * <DataTableDragAlongCell cell={cell}>
 *   {flexRender(cell.column.columnDef.cell, cell.getContext())}
 * </DataTableDragAlongCell>
 */
export function DataTableDragAlongCell<TData extends RowData, TValue>(
  props: DataTableDragAlongCellProps<TData, TValue>,
) {
  return <TableDragAlongCell<TData, TValue> {...props} />
}

DataTableDragAlongCell.displayName = "DataTableDragAlongCell"
src/components/niko-table/core/data-table-column-dnd-structure.tsx
"use client"

/**
 * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
 *
 * Before reporting anything: please check the changelog first.
 *  - In-repo: ./CHANGELOG.md
 *  - Docs site: https://niko-table.com/changelog
 *
 * Found a bug or have a fix? Open an issue or PR on GitHub so other
 * users (and future LLMs reading this code) benefit:
 * https://github.com/Semkoo/niko-table-registry
 */
import React from "react"
import { cn } from "@/lib/utils"
import { useDataTable } from "./data-table-context"
import {
  TableHeader,
  TableRow,
  TableBody,
  TableCell,
} from "@/components/ui/table"
import { flexRender } from "@tanstack/react-table"
import { DataTableColumnHeaderRoot } from "../components/data-table-column-header"
import { DataTableColumnResizeHandle } from "../lib/column-resize-handle"
import { DataTableRowContextMenu } from "../components/data-table-row-context-menu"
import { useResolvedRowContextMenuRenderer } from "../components/data-table-row-context-menu-slot"
import { resolveRowFromClick } from "../lib/row-click"
import { renderCellContent } from "../lib/render-cell-content"
import {
  TableDraggableHeader,
  TableDragAlongCell,
} from "../filters/table-column-dnd"

import type { DataTableRow } from "../types"
import type { RowData } from "@tanstack/react-table"
// ============================================================================
// DndColumnBodyRow — memoized row
// ============================================================================

/**
 * Per-row component for `DataTableDndColumnBody` (column-DnD). Memoized
 * to avoid cascading reconciliation across all visible rows on selection
 * or expansion changes. `TableDragAlongCell` handles per-cell drag state
 * internally, so cell-level memoization isn't required here.
 */
interface DndColumnBodyRowProps {
  row: DataTableRow<RowData>
  expandColumnId: string | undefined
  isClickable: boolean
  isExpanded: boolean
  isSelected: boolean
  /** Column resizing is on — cells size from `column.getSize()` instead of `columnDef.size`. */
  columnSizingEnabled: boolean
  /** Column layout signature — invalidates React.memo on visibility/order/pinning change. */
  columnLayoutSignature: string
  /**
   * Per-row memo key. Change this string to force React.memo to re-render a
   * specific row when row-level state changes outside of TanStack Table's
   * tracked props (e.g. inline edit mode, optimistic state).
   */
  rowMemoKey: string
  /**
   * Right-click menu items for this row. Must be a stable callback (wrap in
   * `useCallback`) so `React.memo` keeps holding. Return `null` to opt a
   * specific row out of having a menu.
   */
  renderRowContextMenu?: (row: unknown) => React.ReactNode
}

const DndColumnBodyRow = React.memo(function DndColumnBodyRow({
  row,
  expandColumnId,
  isClickable,
  isExpanded,
  isSelected,
  columnSizingEnabled,
  renderRowContextMenu,
}: DndColumnBodyRowProps) {
  const expandCell =
    isExpanded && expandColumnId
      ? row.getAllCells().find(c => c.column.id === expandColumnId)
      : undefined

  const visibleCells = row.getVisibleCells()

  const rowElement = (
    <TableRow
      data-row-id={row.id}
      data-state={isSelected ? "selected" : undefined}
      className={cn(
        isClickable && "cursor-pointer",
        "group data-[context-menu-open]:bg-muted/50",
      )}
    >
      {visibleCells.map(cell => {
        const size = cell.column.columnDef.size
        const cellStyle = {
          // Resizing: width tracks `getSize()`; off: unchanged.
          width: columnSizingEnabled
            ? cell.column.getSize()
            : size
              ? `${size}px`
              : undefined,
        }

        return (
          <TableDragAlongCell
            key={cell.id}
            cell={cell}
            style={cellStyle}
            className={cn(
              !cell.getIsGrouped() && "truncate",
              cell.column.getIsPinned() &&
                "bg-background group-hover:bg-muted/50 group-data-[context-menu-open]:bg-muted/50 group-data-[state=selected]:bg-muted",
            )}
          >
            {renderCellContent(cell)}
          </TableDragAlongCell>
        )
      })}
    </TableRow>
  )

  // Only stand up the context-menu shell when the consumer supplies items
  // for this row — a `null` return keeps the plain row.
  const menuItems = renderRowContextMenu?.(row.original)

  return (
    <>
      {menuItems ? (
        <DataTableRowContextMenu row={row.original} trigger={rowElement}>
          {menuItems}
        </DataTableRowContextMenu>
      ) : (
        rowElement
      )}

      {expandCell && (
        <TableRow>
          <TableCell colSpan={visibleCells.length} className="p-0">
            {expandCell.column.columnDef.meta?.expandedContent?.(row.original)}
          </TableCell>
        </TableRow>
      )}
    </>
  )
})

DndColumnBodyRow.displayName = "DndColumnBodyRow"

// ============================================================================
// DataTableDndHeader (Column DnD)
// ============================================================================

export interface DataTableDndHeaderProps {
  className?: string
  /**
   * Makes the header sticky at the top when scrolling.
   * @default true
   */
  sticky?: boolean
}

/**
 * DnD-aware table header that renders column headers as draggable items.
 * Drop-in replacement for DataTableHeader when using column drag-and-drop.
 *
 * Must be wrapped in a DataTableColumnDndProvider (or TableColumnDndProvider).
 *
 * @example
 * <DataTableColumnDndProvider columnOrder={columnOrder} onColumnOrderChange={setColumnOrder}>
 *   <DataTable>
 *     <DataTableDndHeader />
 *     <DataTableDndColumnBody />
 *   </DataTable>
 * </DataTableColumnDndProvider>
 */
export const DataTableDndHeader = React.memo(function DataTableDndHeader({
  className,
  sticky = true,
}: DataTableDndHeaderProps) {
  const { table } = useDataTable()
  const resizing = table?.options.enableColumnResizing ?? false

  const headerGroups = table?.getHeaderGroups() ?? []

  if (headerGroups.length === 0) {
    return null
  }

  return (
    <TableHeader
      className={cn(
        sticky && "sticky top-0 z-30 bg-background",
        sticky &&
          "after:absolute after:right-0 after:bottom-0 after:left-0 after:h-px after:bg-border",
        className,
      )}
    >
      {headerGroups.map(headerGroup => (
        <TableRow key={headerGroup.id}>
          {headerGroup.headers.map(header => {
            const size = header.column.columnDef.size
            return (
              <TableDraggableHeader
                key={header.id}
                header={header}
                style={{
                  width: resizing
                    ? header.getSize()
                    : size
                      ? `${size}px`
                      : undefined,
                }}
              >
                {header.isPlaceholder ? null : (
                  <DataTableColumnHeaderRoot column={header.column}>
                    {flexRender(
                      header.column.columnDef.header,
                      header.getContext(),
                    )}
                  </DataTableColumnHeaderRoot>
                )}
                {resizing && header.column.getCanResize() && (
                  <DataTableColumnResizeHandle header={header} />
                )}
              </TableDraggableHeader>
            )
          })}
        </TableRow>
      ))}
    </TableHeader>
  )
})

DataTableDndHeader.displayName = "DataTableDndHeader"

// ============================================================================
// DataTableDndColumnBody (Column DnD)
// ============================================================================

export interface DataTableDndColumnBodyProps<TData extends RowData> {
  children?: React.ReactNode
  className?: string
  /**
   * Click is delegated on `<tbody>`. The event's `currentTarget`
   * is therefore the `<tbody>` — typed as `HTMLElement` to stay
   * consistent with the virtualized variants. Consumers needing
   * the row element can `event.target.closest("tr[data-row-id]")`.
   */
  onRowClick?: (row: TData, event: React.MouseEvent<HTMLElement>) => void
  /**
   * Return a per-row memo invalidation key. When the returned string changes
   * for a specific row, React.memo re-renders that row even if TanStack Table
   * props (selection, expansion, column layout) are unchanged. Use this for
   * row-level external state that cell renderers depend on — e.g. inline edit
   * mode, optimistic overlays, or any closure-captured state in column
   * definitions that changes independently of the table's own state.
   *
   * @example
   * // Trigger re-render on inline edit toggle (only the edited row re-renders)
   * getRowMemoKey={(row) => (isEditing(row.id) ? "editing" : "")}
   */
  getRowMemoKey?: (row: TData) => string
  /**
   * Attach a native right-click context menu to each row. Return the menu
   * items (`ContextMenuItem`, `ContextMenuSeparator`, `ContextMenuSub`, …)
   * for the given row, or `null` to give that row no menu. The popup shell
   * and portalling are handled internally.
   *
   * Wrap the callback in `useCallback` so memoized rows don't re-render.
   */
  renderRowContextMenu?: (row: TData) => React.ReactNode
}

/**
 * DnD-aware table body for column drag-and-drop.
 * Each cell is wrapped with useSortable to follow column drag position.
 *
 * Must be wrapped in a DataTableColumnDndProvider (or TableColumnDndProvider).
 *
 * @example
 * <DataTableColumnDndProvider columnOrder={columnOrder} onColumnOrderChange={setColumnOrder}>
 *   <DataTable>
 *     <DataTableDndHeader />
 *     <DataTableDndColumnBody />
 *   </DataTable>
 * </DataTableColumnDndProvider>
 */
export function DataTableDndColumnBody<TData extends RowData>({
  children,
  className,
  onRowClick,
  getRowMemoKey,
  renderRowContextMenu,
}: DataTableDndColumnBodyProps<TData>) {
  const { table, columns, isLoading } = useDataTable<TData>()
  const { rows } = table.getRowModel()
  const containerRef = React.useRef<HTMLTableSectionElement>(null)

  /** Single row-click handler with event delegation (useCallback). */
  const handleRowClick = React.useCallback(
    (event: React.MouseEvent<HTMLTableSectionElement>) => {
      if (!onRowClick) return
      const row = resolveRowFromClick(event.target as HTMLElement, table)
      if (!row) return
      onRowClick(row.original, event)
    },
    [onRowClick, table],
  )

  // Resolve the expand column once at the table level (see comment
  // on the equivalent memo in `DataTableDndBody` above).
  const expandColumnId = React.useMemo(
    () =>
      table.getAllColumns().find(col => col.columnDef.meta?.expandedContent)
        ?.id,
    // `columns` keeps the memo in sync when consumers swap column sets —
    // `table` alone is too stable (TanStack reuses the same instance).
    [table, columns],
  )

  // String signature of the visible column layout. Memoized rows compare it
  // to invalidate on column toggle / reorder / pin / resize. For external row
  // state (inline edits, optimistic overlays), pass `getRowMemoKey`.
  const { columnVisibility, columnOrder, columnPinning, columnSizing } =
    table.state
  const resizing = table.options.enableColumnResizing ?? false
  const columnLayoutSignature = React.useMemo(
    () =>
      table
        .getVisibleLeafColumns()
        .map(c => {
          const pinned = c.getIsPinned()
          const base = pinned ? `${c.id}:${pinned}` : c.id
          return resizing ? `${base}:${c.getSize()}` : base
        })
        .join(","),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
      table,
      columns,
      columnVisibility,
      columnOrder,
      columnPinning,
      columnSizing,
      resizing,
    ],
  )

  const isClickable = !!onRowClick

  // Composable path: the per-row menu may come from the `renderRowContextMenu`
  // prop OR a nested `<DataTableRowContextMenuSlot>` child (prop wins).
  const resolvedRenderRowContextMenu = useResolvedRowContextMenuRenderer(
    renderRowContextMenu,
    children,
  )

  // Capture / restore table inline sizing when resize turns on (same as
  // `DataTableDndBody`) so `w-full` cannot compress columns while dragging.
  const tableStyleSnapshotRef = React.useRef<{
    tableLayout: string
    width: string
    minWidth: string
  } | null>(null)

  React.useLayoutEffect(() => {
    const tableEl = containerRef.current?.closest<HTMLTableElement>(
      '[data-slot="table"]',
    )
    if (!tableEl) return

    const restore = () => {
      const snap = tableStyleSnapshotRef.current
      if (!snap) return
      tableEl.style.tableLayout = snap.tableLayout
      tableEl.style.width = snap.width
      tableEl.style.minWidth = snap.minWidth
      tableStyleSnapshotRef.current = null
    }

    if (!resizing) {
      restore()
      return
    }

    if (!tableStyleSnapshotRef.current) {
      tableStyleSnapshotRef.current = {
        tableLayout: tableEl.style.tableLayout,
        width: tableEl.style.width,
        minWidth: tableEl.style.minWidth,
      }
    }
    const totalDesiredWidth = table
      .getVisibleLeafColumns()
      .reduce((sum, col) => sum + col.getSize(), 0)
    tableEl.style.tableLayout = "fixed"
    tableEl.style.width = `${totalDesiredWidth}px`
    tableEl.style.minWidth = `${totalDesiredWidth}px`
    return restore
  }, [
    resizing,
    table,
    columnSizing,
    columnVisibility,
    columnOrder,
    columnPinning,
  ])

  return (
    <TableBody
      ref={containerRef}
      className={className}
      onClick={onRowClick ? handleRowClick : undefined}
    >
      {!isLoading && rows?.length
        ? rows.map(row => (
            <DndColumnBodyRow
              key={row.id}
              row={row as DataTableRow<RowData>}
              expandColumnId={expandColumnId}
              isClickable={isClickable}
              isExpanded={row.getIsExpanded()}
              isSelected={row.getIsSelected()}
              columnSizingEnabled={resizing}
              columnLayoutSignature={columnLayoutSignature}
              rowMemoKey={
                getRowMemoKey ? getRowMemoKey(row.original as TData) : ""
              }
              renderRowContextMenu={
                resolvedRenderRowContextMenu as
                  ((row: unknown) => React.ReactNode) | undefined
              }
            />
          ))
        : null}

      {children}
    </TableBody>
  )
}

DataTableDndColumnBody.displayName = "DataTableDndColumnBody"

More from niko-table

Browse all components in this library →