Data-Driven Drag-and-Drop Library for Vanilla JS – dnd-manager

Category: Drag & Drop , Javascript | April 9, 2026
Authordendrell
Last UpdateApril 9, 2026
LicenseMIT
Tags
Views0 views
Data-Driven Drag-and-Drop Library for Vanilla JS – dnd-manager

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 canDrag callback 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-dragging and data-hovered attributes 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 requestAnimationFrame for 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[]): The data-kind value or values that mark elements as draggable. Required.
  • droppableKind (string | string[]): The data-kind value 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.

You Might Be Interested In:


Leave a Reply