Lightweight, data-driven drag-and-drop for vanilla JS and any framework. Full control over UX without opinionated component patterns.
Live demo: itsjavi.com/dnd-manager · Source: demo/ in this repo · LLM-optimized docs: llms.md
- Installation
- Quick start
- Clone preview helper
- Vanilla JS
- React
- Multiple managers
- Dynamic drag ability
- API overview
pnpm add dnd-manager
# or: npm install dnd-manager- Mark draggable/droppable elements with
data-kind(e.g.data-kind="cell"). - Add position data (e.g.
data-row,data-colfor a grid). - Create a
DragDropManagerwith callbacks that read position/data and handle drag/drop.
import { DragDropManager, type DragDropCallbacks } from 'dnd-manager'
type Item = { id: string; name: string }
type Position = { row: number; col: number }
const callbacks: DragDropCallbacks<Item, Position> = {
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 ?? '' }),
onDrop: (from, to, item) => {
/* swap/update DOM or state */
},
onDragEnd: (result) => {
/* result or null; cleanup preview etc. */
},
}
const manager = new DragDropManager<Item, Position>(
document.querySelector('#container'),
{ draggableKind: 'cell', droppableKind: 'cell' },
callbacks,
)
// On teardown:
manager.destroy()Elements with data-empty are not draggable. Use onDragStart / onDragMove to show a preview;
use onDragEnd(result) for cleanup and optional reordering (see below).
DragPreviewController gives a clone of the dragged element that follows the cursor
(position: fixed, pointer-events: none). Reduces drift in scrolled containers.
import { DragPreviewController, DragDropManager, type DragDropCallbacks } from 'dnd-manager'
const preview = new DragPreviewController({ zIndex: 9999, opacity: 0.95 })
const callbacks: DragDropCallbacks<Item, Position> = {
getItemPosition: (el) => ({ index: Number(el.dataset.index) }),
getItemData: (el, pos) => ({ index: pos.index }),
onDragStart: (element) => preview.startFromElement(element),
onDragMove: (pos) => preview.moveToPointer(pos),
onDragEnd: () => preview.stop(),
}
const manager = new DragDropManager(
container,
{ draggableKind: 'ITEM', droppableKind: 'ITEM' },
callbacks,
)
// Cleanup:
preview.destroy()
manager.destroy()Use a container of elements with data-kind, data-row, data-col, and item fields
(data-item-id, data-name, etc.). In callbacks:
- getItemPosition — parse position from data attributes.
- getItemData — return item payload (e.g. id, name, color).
- onDragStart — show preview (e.g. copy size/style to a fixed preview element).
- onDragMove — update preview position (
left/topor transform). - onDrop — swap/update DOM (or state) using source/target positions and
sourceItem. - onDragEnd(result) —
resultis{ sourcePosition, targetPosition, sourceItem }on valid drop, ornullwhen cancelled. Use for cleanup (hide preview) and optionally do reordering here instead of inonDrop. - onClick — handle click (e.g. log or select).
Full grid markup and CSS are in the demo app (see GridDemo and styles). Minimal markup
pattern:
<div id="grid-container">
<div class="cell" data-kind="cell" data-row="0" data-col="0" data-item-id="1" data-name="Item 1">
Item 1
</div>
<!-- ... more cells; use data-empty on empty slots -->
</div>
<div id="drag-preview"></div>You can do all post-drag work in onDragEnd so cleanup and DOM/state updates stay in one place:
onDragEnd: (result) => {
if (result === null) return
const { sourcePosition, targetPosition, sourceItem } = result
// Update DOM or state (e.g. swap elements), then cleanup
swapElements(container, sourcePosition, targetPosition, sourceItem)
preview.style.opacity = '0'
}Create the manager inside useEffect and clean up on unmount. Use state for grid data and preview;
update state in onDrop (or onDragEnd). Keep getItemData reading from current state so the
manager always has up-to-date item data.
useEffect(() => {
if (!containerRef.current) return
const callbacks: DragDropCallbacks<GridItem, GridPosition> = {
getItemPosition: (el) => ({ row: +el.dataset.row!, col: +el.dataset.col! }),
getItemData: (_, pos) => gridData[pos.row][pos.col],
onDragStart: (el, _, item) => setDragPreview({ item, rect: el.getBoundingClientRect() }),
onDragMove: (pos) => setDragPreview((p) => (p ? { ...p, position: pos } : p)),
onDrop: (from, to, item) => setGridData((prev) => swap(prev, from, to, item)),
onDragEnd: () => setDragPreview(null),
}
const manager = new DragDropManager(
containerRef,
{ draggableKind: 'cell', droppableKind: 'cell' },
callbacks,
)
return () => manager.destroy()
}, [gridData])Render cells with data-kind, data-row, data-col, and item attributes; render a fixed-position
preview from dragPreview. Full component and CSS: demo app.
Use two (or more) DragDropManager instances with compatible data-kind and a shared
callbacks object. Ensure positions identify items globally (e.g. { containerId, itemId }) so
onDrop / onDragEnd can update both containers or shared state.
onDrop runs on the manager where the drag started. onDragEnd(result) runs for every drag;
result is the drop payload or null.
const callbacks: DragDropCallbacks<Item, Position> = {
getItemPosition: (el) => ({
containerId: el.closest('[data-container]')?.dataset.container as 'left' | 'right',
itemId: el.dataset.id!,
}),
getItemData: (el) => ({ id: el.dataset.id!, label: el.dataset.label! }),
onDrop: (sourcePos, targetPos, sourceItem) => {
// Update DOM or state for both source and target containers
},
}
const leftManager = new DragDropManager(
leftEl,
{ draggableKind: 'cell', droppableKind: 'cell' },
callbacks,
)
const rightManager = new DragDropManager(
rightEl,
{ draggableKind: 'cell', droppableKind: 'cell' },
callbacks,
)
// Cleanup: destroy bothControl whether an item can be dragged (e.g. by permissions or locked state) without reinitializing the manager.
canDrag(element, position)— optional callback; called on every pointer down. Returntrueto allow drag,falseto block.- Keep the callbacks object stable and either:
- Mutate
callbacks.canDragwhen permissions/state change (vanilla), or - Have
canDragread from a ref or store you update (e.g. in React) so the same manager instance always sees current state.
- Mutate
Vanilla: update canDrag when state changes:
const callbacks = { getItemPosition, getItemData, canDrag: () => true }
function setCanDrag(fn: (el: HTMLElement, pos: Position) => boolean) {
callbacks.canDrag = fn
}
setCanDrag((el, pos) => !getItemData(el, pos)?.locked && userCanEdit)
const manager = new DragDropManager(container, config, callbacks)React: use a ref that always holds latest state; canDrag reads from it so the manager doesn’t
need to be recreated when state changes.
| Callback | Purpose |
|---|---|
getItemPosition(element, kind) |
Return position object from DOM (e.g. { row, col }) or null. |
getItemData(element, position) |
Return item data for the element at position. |
canDrag(element, position) |
Optional. Return false to block drag on this pointer down. |
onDragStart(element, position, item) |
Show preview, update source appearance. |
onDragMove(pointerPosition, hoveredElement) |
Update preview position. |
onDrop(sourcePosition, targetPosition, sourceItem) |
Called when dropped on valid target; update DOM/state. |
onDragEnd(result) |
Always called when drag ends. result = { sourcePosition, targetPosition, sourceItem } or null. Use for cleanup and optional reordering. |
onClick(element, position) |
Click/tap on draggable element. |
| Option | Description |
|---|---|
draggableKind / droppableKind |
String or string[] to match data-kind. |
dragThreshold |
Pixels before drag starts (default 10). |
clickThreshold |
Max movement to still count as click (default 10). |
scrollThreshold |
Distance from viewport edge to start auto-scroll (default 100). |
scrollSpeed |
Auto-scroll speed (default 10). |
cancelOnEscape |
Cancel drag on Escape (default true). |
cancelOnPointerLeave |
Cancel when pointer leaves window (default true). |
data-kind— identifies draggable/droppable type; must match config.data-empty— elements with this attribute are not draggable.data-dragging— set to"true"on the source element during drag; removed on end/cancel.data-hovered— set to"true"on the hovered droppable; removed when pointer leaves or drag ends.- Preview — implement in
onDragStart/onDragMove/onDragEnd, or useDragPreviewController.
- Uses
requestAnimationFramefor 60fps updates; pointer events work with touch, mouse, and pen. - React: create manager in
useEffect, depend on grid state ifgetItemDatauses it, and destroy on cleanup.