
dnd-manager is a vanilla JavaScript library that creates drag-and-drop interactions through a data-driven callback API. It reads item positions and data directly from the DOM via HTML data attributes, fires a structured sequence of callbacks as the user drags, and leaves all rendering decisions to your code.
The library works with any framework by accepting a CSS selector, a plain HTMLElement, or a React-style ref as its container. It uses the Pointer Events API for full touch, mouse, and pen input support.
Features:
- Lightweight and no external dependencies.
- Reads position and item data from HTML data attributes at interaction time.
- Fires a structured callback lifecycle on drag start, move, drop, and end events.
- Supports a
canDragcallback for per-item drag permission control at pointer-down time. - Includes a CSS class that clones the dragged element and tracks the cursor with fixed positioning.
- Auto-scrolls the viewport when the pointer approaches the window edge during drag.
- Sets
data-dragginganddata-hoveredattributes on the relevant elements automatically during drag. - Supports multiple manager instances sharing a single callbacks object for cross-container drag operations.
- Cancels drag on Escape key or when the pointer leaves the window.
- Uses
requestAnimationFramefor 60fps drag move updates.
Use Cases:
- Reorderable grid layouts where each cell carries its own position data as attributes.
- Kanban boards with multiple columns that share a common drag manager callbacks object.
- Inventory or asset panels where individual items carry lock states that block dragging.
- React applications that need a low-level drag primitive to drive state updates without coupling to a specific UI component model.
How To Use It:
1. Install dnd-manager via npm or pnpm:
# Yarn $ yarn add dnd-manager # NPM $ npm install dnd-manager
2. Every draggable element needs a data-kind attribute that matches the configuration and position attributes that your callbacks will parse. Empty slots use data-empty to opt out of dragging.
<div id="card-grid">
<!-- data-kind must match draggableKind and droppableKind in config -->
<div class="card" data-kind="card" data-row="0" data-col="0"
data-item-id="a1" data-name="Project Alpha">
Project Alpha
</div>
<div class="card" data-kind="card" data-row="0" data-col="1"
data-item-id="a2" data-name="Project Beta">
Project Beta
</div>
<!-- data-empty marks this slot as non-draggable -->
<div class="card" data-kind="card" data-row="0" data-col="2" data-empty></div>
</div>
<!-- Fixed-position preview element; hidden by default -->
<div id="drag-preview" style="position:fixed;pointer-events:none;opacity:0;"></div>3. Basic grid setup:
import { DragDropManager, type DragDropCallbacks } from 'dnd-manager'
// Define the shape of your item data and position
type CardItem = { id: string; name: string; color: string }
type GridPos = { row: number; col: number }
const grid = document.getElementById('card-grid')!
const preview = document.getElementById('drag-preview')!
const callbacks: DragDropCallbacks<CardItem, GridPos> = {
// Parse row and col from the element's data attributes
getItemPosition: (el) => {
const row = el.dataset.row, col = el.dataset.col
return row != null && col != null ? { row: +row, col: +col } : null
},
// Read item payload from data attributes
getItemData: (el) => {
const { itemId: id, name } = el.dataset
const color = el.style.backgroundColor
return id && name ? { id, name, color } : null
},
// Size and style the preview when drag begins
onDragStart: (el, _pos, item) => {
const rect = el.getBoundingClientRect()
Object.assign(preview.style, {
width: `${rect.width}px`,
height: `${rect.height}px`,
background: item.color,
opacity: '0.9',
})
preview.textContent = item.name
},
// Move the preview to follow the pointer
onDragMove: (pos) => {
preview.style.left = `${pos.x}px`
preview.style.top = `${pos.y}px`
},
// Swap DOM nodes when the drop lands on a valid target
onDrop: (from, to) => {
const src = grid.querySelector<HTMLElement>(
`[data-row="${from.row}"][data-col="${from.col}"]`
)
const tgt = grid.querySelector<HTMLElement>(
`[data-row="${to.row}"][data-col="${to.col}"]`
)
if (src && tgt) swapCells(src, tgt) // your swap helper
},
// Hide the preview when drag ends or is cancelled
onDragEnd: () => { preview.style.opacity = '0' },
onClick: (el) => console.log('selected card:', el.dataset.itemId),
}
const manager = new DragDropManager<CardItem, GridPos>(
grid,
{ draggableKind: 'card', droppableKind: 'card' },
callbacks,
)
// Call on page teardown or component unmount
manager.destroy()4. The manual preview approach above works fine, but the DragPreviewController handles drift correction in scrolled containers automatically. It clones the source element, appends the clone to document.body, and moves it with CSS transforms.
import {
DragDropManager,
DragPreviewController,
type DragDropCallbacks,
} from 'dnd-manager'
// Options control the clone's z-index, opacity, and optional CSS classes
const preview = new DragPreviewController({
zIndex: 9999,
opacity: 0.9,
className: 'card-drag-clone', // applied to the cloned element
})
const callbacks: DragDropCallbacks<CardItem, GridPos> = {
getItemPosition: (el) => {
const row = el.dataset.row, col = el.dataset.col
return row != null && col != null ? { row: +row, col: +col } : null
},
getItemData: (el, pos) => ({
id: el.dataset.itemId ?? '',
name: el.dataset.name ?? '',
color: el.style.backgroundColor,
}),
// Clone the element and begin tracking the cursor
onDragStart: (el) => preview.startFromElement(el),
// Update clone position on every pointer move
onDragMove: (pos) => preview.moveToPointer(pos),
onDrop: (from, to, item) => {
// Update your DOM or state here
},
// Remove the clone when drag ends
onDragEnd: () => preview.stop(),
}
const manager = new DragDropManager(
document.getElementById('card-grid')!,
{ draggableKind: 'card', droppableKind: 'card' },
callbacks,
)
// Destroy both on teardown
preview.destroy()
manager.destroy()5. React Integration. Create the manager inside useEffect. Use a ref for the container and state for grid data. The getItemData callback reads from current state on each interaction, so the manager does not need to be recreated every time state changes unless the data shape itself changes.
import { useEffect, useRef, useState } from 'react'
import { DragDropManager, type DragDropCallbacks } from 'dnd-manager'
type GridItem = { id: string; label: string }
type GridPos = { row: number; col: number }
type DragPreview = {
item: GridItem
position: { x: number; y: number } | null
width: number
height: number
}
export function CardGrid() {
const containerRef = useRef<HTMLDivElement>(null)
const [gridData, setGridData] = useState<(GridItem | null)[][]>(INITIAL_DATA)
const [preview, setPreview] = useState<DragPreview | null>(null)
useEffect(() => {
if (!containerRef.current) return
const callbacks: DragDropCallbacks<GridItem, GridPos> = {
// Parse position from element attributes
getItemPosition: (el) => {
const row = el.dataset.row, col = el.dataset.col
return row != null && col != null ? { row: +row, col: +col } : null
},
// Always read from the latest gridData snapshot
getItemData: (_, pos) => gridData[pos.row]?.[pos.col] ?? null,
// Capture element dimensions to size the preview
onDragStart: (el, _pos, item) => {
const rect = el.getBoundingClientRect()
setPreview({ item, position: null, width: rect.width, height: rect.height })
},
// Track pointer position for the preview element
onDragMove: (pos) => setPreview((p) => p ? { ...p, position: pos } : p),
// Swap items in state on a valid drop
onDrop: (from, to, item) => {
setGridData((prev) => {
const next = prev.map((row) => [...row])
next[to.row][to.col] = item
next[from.row][from.col] = prev[to.row][to.col]
return next
})
},
onDragEnd: () => setPreview(null),
}
// containerRef works directly; no need to pass .current
const manager = new DragDropManager<GridItem, GridPos>(
containerRef,
{ draggableKind: 'card', droppableKind: 'card' },
callbacks,
)
// Clean up listeners when gridData changes and effect reruns
return () => manager.destroy()
}, [gridData])
return (
<>
<div ref={containerRef} id="card-grid">
{gridData.map((row, r) =>
row.map((item, c) => (
<div
key={`${r}-${c}`}
className="card"
data-kind="card"
data-row={r}
data-col={c}
data-item-id={item?.id}
data-name={item?.label}
{...(item ? {} : { 'data-empty': '' })}
>
{item?.label ?? ''}
</div>
))
)}
</div>
{/* Fixed-position preview rendered from state */}
{preview?.position && (
<div
style={{
position: 'fixed',
pointerEvents: 'none',
left: preview.position.x,
top: preview.position.y,
width: preview.width,
height: preview.height,
opacity: 0.85,
transform: 'translate(-50%, -50%)',
}}
>
{preview.item.label}
</div>
)}
</>
)
}6. Cross-Container Drag (Multiple Managers). Two DragDropManager instances can share a single callbacks object. Positions must encode the container identity so onDrop knows which container each item came from and which it landed in.
type Position = { containerId: 'queue' | 'done'; itemId: string }
type Task = { id: string; title: string }
const callbacks: DragDropCallbacks<Task, Position> = {
// Walk up the DOM to find the container identifier
getItemPosition: (el) => {
const containerId = el.closest<HTMLElement>('[data-container]')
?.dataset.container as Position['containerId']
const itemId = el.dataset.id
return containerId && itemId ? { containerId, itemId } : null
},
getItemData: (el) => ({
id: el.dataset.id ?? '',
title: el.dataset.title ?? '',
}),
// Update both containers when items move between them
onDrop: (from, to, task) => {
moveTask(from, to, task) // your state/DOM update helper
},
}
const queueManager = new DragDropManager(
document.getElementById('queue')!,
{ draggableKind: 'task', droppableKind: 'task' },
callbacks,
)
const doneManager = new DragDropManager(
document.getElementById('done')!,
{ draggableKind: 'task', droppableKind: 'task' },
callbacks,
)
// Destroy both on teardown
queueManager.destroy()
doneManager.destroy()7. Dynamic Drag Permission with canDrag. The canDrag callback fires on every pointerdown before a drag can start. In vanilla JS, mutate the callbacks object directly. In React, read from a ref so the manager always sees the current value without being recreated.
// Vanilla JS: update canDrag in place when permissions change
const callbacks: DragDropCallbacks<Task, Position> = {
getItemPosition: (el) => ({ index: +el.dataset.index! }),
getItemData: (el) => ({ id: el.dataset.id!, locked: el.dataset.locked === 'true' }),
canDrag: () => true, // default: allow all
}
// Call this when user role or item state changes
function updateDragPermission(userCanEdit: boolean) {
callbacks.canDrag = (el, pos) => {
const item = callbacks.getItemData?.(el, pos)
return Boolean(userCanEdit && item && !item.locked)
}
}// React: hold latest state in a ref; canDrag reads from it
const tasksRef = useRef(tasks)
tasksRef.current = tasks
const callbacksRef = useRef<DragDropCallbacks<Task, { index: number }>>({
getItemPosition: (el) => ({ index: +el.dataset.index! }),
getItemData: (_, pos) => tasksRef.current[pos.index] ?? null,
canDrag: (_, pos) => {
const task = tasksRef.current[pos.index]
return Boolean(hasPermission && task && !task.locked)
},
})8. All available configuration options:
draggableKind(string | string[]): Thedata-kindvalue or values that mark elements as draggable. Required.droppableKind(string | string[]): Thedata-kindvalue or values that mark elements as valid drop targets. Required.dragThreshold(number): Pixel distance the pointer must travel before a drag starts. Default:10.clickThreshold(number): Maximum pixel movement still counted as a click. Default:10.scrollThreshold(number): Distance from the viewport edge at which auto-scroll activates during drag. Default:100.scrollSpeed(number): Auto-scroll speed in pixels per frame. Default:10.cancelOnEscape(boolean): Cancels the current drag when the user presses Escape. Default:true.cancelOnPointerLeave(boolean): Cancels the current drag when the pointer leaves the browser window. Default:false(check the source; treat this as per your test results).
9. Callback functions:
// Called on pointerdown: parse row/col (or any shape) from the element getItemPosition(element: HTMLElement, kind: string): TPosition | null // Called after getItemPosition: return the item's data payload getItemData(element: HTMLElement, position: TPosition): TItem | null // Called on every pointerdown before drag starts; return false to block drag canDrag(element: HTMLElement, position: TPosition): boolean // Called when drag threshold is crossed; start your preview here onDragStart(element: HTMLElement, position: TPosition, item: TItem): void // Called on every pointer move during drag (RAF-throttled); update preview position here onDragMove(pos: PointerPosition, hoveredElement: HTMLElement | null): void // Called on a valid drop before onDragEnd; update DOM or state here onDrop(sourcePosition: TPosition, targetPosition: TPosition, sourceItem: TItem): void // Always called when drag ends; result is null if cancelled or dropped on a non-target onDragEnd(result: DragEndResult<TItem, TPosition> | null): void // Called when the pointer goes up within clickThreshold pixels of the down point onClick(element: HTMLElement, position: TPosition): void
10. API methods.
// Get a snapshot of the current drag state (position, item, phase) manager.getState() // returns Readonly<DragState<TItem, TPosition>> // Check whether a drag is currently in progress manager.isDragging() // returns boolean // Remove all event listeners; call this on component unmount or page teardown manager.destroy() // Clone the element, append to document.body, and begin tracking the cursor preview.startFromElement(element: HTMLElement): void // Move the clone to the given pointer coordinates preview.moveToPointer(pos: PointerPosition): void // Remove the clone from the DOM preview.stop(): void // Alias for stop() preview.destroy(): void
11. Style drag and hover feedback using attribute selectors.
/* Style the source element while it is being dragged */
[data-dragging="true"] {
opacity: 0.4;
outline: 2px dashed #888;
}
/* Highlight valid drop targets as the pointer passes over them */
[data-hovered="true"] {
background-color: #e0f0ff;
outline: 2px solid #3b82f6;
}Alternatives:
- SortableJS: A DOM-first drag-and-drop library with built-in sort animations.
- Dragula: A minimal drag-and-drop library focused on container-to-container movement with a simple event model.







