@ptahjs/dnd
@ptahjs/dnd
A lightweight, framework-agnostic drag-and-drop library built on the Pointer Events API. Designed with a plugin-first architecture for building canvas editors, sortable lists, and other interactive UIs.
Features
- Pointer Events based — unified mouse, touch, and stylus support
- Plugin architecture — all behaviors (mirror, drop feedback, auto-scroll, transform) are optional plugins
- RAF-driven rendering — per-frame measure → compute → commit pipeline prevents layout thrashing
- Namespace support — isolate independent drag-and-drop zones within the same page
- Transform controller — drag-scope mode with built-in move, resize, and rotate for canvas editors
- Auto-scroll — edge-triggered scrolling during drag
- Zero dependencies — no runtime dependencies
Installation
npm install @ptahjs/dnd
Quick Start
import { Dnd, MirrorService, DropService } from '@ptahjs/dnd'
import '@ptahjs/dnd/style'
const dnd = new Dnd({ root: '#container' })
dnd.use(new MirrorService())
dnd.use(new DropService())
dnd.on('dragstart', (payload) => console.log('drag started', payload))
dnd.on('drop', (payload) => console.log('dropped', payload))
dnd.on('cancel', (payload) => console.log('cancelled', payload))
HTML Markup
Use data attributes to declare draggable elements and drop targets:
<!-- Root container -->
<div id="container">
<!-- Drop target -->
<div drop>
<!-- Draggable element -->
<div drag data-data='{"id":1,"type":"card"}'>Drag me</div>
</div>
<!-- Combined draggable + drop target -->
<div dragdrop data-namespace="list-a">Item</div>
</div>
| Attribute | Description |
|---|---|
drag |
Marks an element as draggable |
drop |
Marks an element as a drop target |
dragdrop |
Element is both draggable and a drop target |
drag-handle |
Restricts drag to a specific handle element inside the draggable |
data-namespace |
Groups draggable/drop elements — only elements in the same namespace interact |
data-data |
JSON data payload attached to the draggable |
copy |
Creates a copy on drop instead of moving |
drag-scope |
Defines a canvas-style scope for TransformControllerService |
drop-indicator |
Enables directional drop indicator (top, right, bottom, left, or all) |
ignore-mirror |
Dragging this element does not create a mirror clone |
ignore-click |
Clicks on this element do not affect active selection |
resizable / rotatable |
Per-element toggle for resize/rotate handles (defaults to true within drag-scope) |
scale-ratio |
Canvas zoom ratio applied to pointer coordinates in drag-scope mode |
API
new Dnd(config?)
Creates a DnD instance.
| Option | Type | Default | Description |
|---|---|---|---|
root |
HTMLElement | string |
— | Root container element or CSS selector |
threshold |
number |
3 |
Pixel distance before drag is considered started |
Instance methods
// Register a plugin
dnd.use(plugin)
// Set or replace the root container
dnd.setRoot(element)
// Override drop permission (called every frame)
dnd.canDrop = (payload) => payload.data.type !== 'locked'
// Custom mirror rendering
dnd.renderMirror = (ctx) => {
const el = document.createElement('div')
el.textContent = 'Custom mirror'
return el
}
// Event listeners
dnd.on('dragstart', handler)
dnd.on('drag', handler)
dnd.on('drop', handler)
dnd.on('cancel', handler)
dnd.off('drop', handler)
// Clean up everything
dnd.destroy()
dnd.monitor
The current drag session object. Read-only during an active drag.
| Property | Description |
|---|---|
active |
Whether a drag session is active (pointer is down) |
started |
Whether the drag threshold has been exceeded |
x, y |
Current pointer coordinates |
dx, dy |
Delta from drag start |
sourceEl |
The element being dragged |
handleEl |
The handle element that was grabbed |
currentDrop |
The drop target currently under the pointer |
currentAllowed |
Whether canDrop returned true for the current target |
currentDropRect |
Bounding rect of the current drop target |
indicatorRegion |
Active drop indicator direction (top / right / bottom / left) |
data |
Parsed data from the draggable's data-data attribute |
namespace |
Namespace of the drag session |
isCopy |
Whether the draggable has the copy attribute |
Events
All event handlers receive a payload object.
dnd.on('dragstart', ({ source, data, namespace }) => { /* ... */ })
dnd.on('drag', ({ source, currentDrop, currentAllowed, x, y }) => { /* ... */ })
dnd.on('drop', ({ source, currentDrop, indicatorRegion, data }) => { /* ... */ })
dnd.on('cancel', ({ source, data }) => { /* ... */ })
Built-in Plugins (Services)
All services are optional. Register them with dnd.use(new ServiceName()).
MirrorService
Creates a floating clone of the dragged element that follows the pointer. Adds dnd-dragging class to the source element during drag.
dnd.use(new MirrorService())
The clone can be customized via dnd.renderMirror. To disable the mirror for a specific element, add the ignore-mirror attribute to it.
DropService
Maintains currentDrop, currentDropRect, and currentAllowed on the session. Applies dnd-canDrop or dnd-noDrop CSS class to the active drop target.
dnd.use(new DropService())
DropIndicatorService
Shows a directional insertion indicator (top/right/bottom/left) on the active drop target. Requires the drop target to have a drop-indicator attribute.
dnd.use(new DropIndicatorService())
<!-- Enable all four directions -->
<div drop drop-indicator>...</div>
<!-- Enable specific directions only -->
<div drop drop-indicator="top bottom">...</div>
The indicator element has class dnd-indicator and toggles dnd-indicator--top, dnd-indicator--right, dnd-indicator--bottom, dnd-indicator--left direction classes.
AutoScrollService
Automatically scrolls the nearest scrollable ancestor (or window) when the pointer approaches the edge of the container during drag.
dnd.use(new AutoScrollService({
edge: 48, // Edge trigger distance in px (default: 48)
minSpeed: 180, // Minimum scroll speed in px/s (default: 180)
maxSpeed: 600, // Maximum scroll speed in px/s (default: 600)
allowWindowScroll: true // Allow scrolling window (default: true)
}))
ActiveSelectionService
Tracks the selected (active) draggable per namespace. Adds dnd-active class to the selected element. Clicking outside clears the selection.
Within a drag-scope container, selecting an element injects resize/rotate handles into it.
dnd.use(new ActiveSelectionService())
TransformControllerService
Enables move, resize, and rotate within a drag-scope container. Designed for canvas editors with elements that use CSS transform for positioning.
dnd.use(new TransformControllerService({
resizable: true,
rotatable: true,
boundary: true, // Constrain movement within drag-scope
scaleRatio: 1, // Canvas zoom ratio (can also be set via data-scale-ratio)
snapToGrid: false,
gridX: 10,
gridY: 10,
aspectRatio: undefined, // Lock aspect ratio during resize
minWidth: 10,
minHeight: 10,
maxWidth: 0, // 0 = no limit
maxHeight: 0,
rotateSnap: false,
rotateStep: 15, // Snap angle in degrees
snap: true, // Snap-to-element alignment
snapThreshold: 10, // Snap threshold in px
markline: true, // Show alignment guide lines
}))
Emitted events (via dnd.on):
| Event | Description |
|---|---|
draggable:drag |
Element moved — payload includes el, x, y, width, height, angle |
draggable:resize |
Element resized — same payload |
draggable:rotate |
Element rotated — same payload |
draggable:drop |
Drag ended with a committed transform — includes final x, y, width, height, angle |
HTML markup for drag-scope:
<div drag-scope data-scale-ratio="1">
<div drag resizable rotatable
style="transform: translate(100px, 50px); width: 200px; height: 120px;">
Canvas element
</div>
</div>
CSS Classes
| Class | Applied to | Description |
|---|---|---|
dnd-dragging |
Source element | While drag is active |
dnd-mirror |
Mirror clone | The floating drag ghost |
dnd-active |
Selected draggable | While element is selected |
dnd-canDrop |
Drop target | When canDrop returns true |
dnd-noDrop |
Drop target | When canDrop returns false |
dnd-indicator |
Indicator element | The drop direction indicator |
dnd-indicator-active |
Indicator element | When indicator is visible |
dnd-indicator--top/right/bottom/left |
Indicator element | Active direction |
Writing a Custom Plugin
A plugin is a plain object or class instance with optional lifecycle hooks. Register it with dnd.use(plugin).
const myPlugin = {
order: 50, // lower = earlier execution
onAttach(dnd) {
// Called once when plugin is registered
},
onRootChange(nextRoot, prevRoot, signal) {
// Called when dnd.setRoot() is called
nextRoot.addEventListener('contextmenu', handler, { signal })
},
onDown(ctx, event) {
// Pointer down — drag not yet started
},
onStart(ctx) {
// Drag threshold exceeded, drag is now active
},
onMeasure(ctx) {
// Read-only DOM measurements (called every frame)
},
onCompute(ctx) {
// Pure calculations based on measurements (no DOM writes)
},
onCommit(ctx) {
// Write DOM changes via ctx.frame to batch updates
ctx.frame.toggleClass(element, 'my-class', true)
ctx.frame.setStyle(element, 'opacity', '0.5')
},
onAfterDrag(ctx) {
// Post-commit hook, e.g. for auto-scroll
// Return true or { scrolled: true } to request a re-render next frame
},
onEnd(ctx, meta) {
// meta.ended: true = drop, false = cancel
// meta.reason: 'pointerup' | 'blur' | 'destroy'
},
onDestroy(dnd, session) {
// Cleanup when dnd.destroy() is called
}
}
dnd.use(myPlugin)
Context object (ctx)
| Property | Description |
|---|---|
ctx.session |
The current drag session (monitor) |
ctx.store |
Cross-session store (selectedByNs map) |
ctx.frame |
Frame command queue for batched DOM writes |
ctx.adapter |
DOM adapter for measurements and hit testing |
ctx.dnd |
The Dnd instance |
ctx.payload(type) |
Builds an event payload for the given event type |
Architecture
Dnd (Facade)
├── PointerSensor — captures pointerdown/move/up events
├── State — finite state machine (DOWN → MOVE → END)
├── DomAdapter — DOM queries and per-frame rect caching
├── RafScheduler — requestAnimationFrame loop (only runs when dirty)
├── FrameContext — batched DOM command queue for the current frame
└── PluginRuntime — ordered plugin lifecycle dispatcher
├── MirrorService
├── DropService
├── DropIndicatorService
├── AutoScrollService
├── ActiveSelectionService
└── TransformControllerService
Per-frame pipeline (while dragging):
pointermove → State.dispatch(MOVE) → scheduler.request()
↓
requestAnimationFrame
↓
PluginRuntime.onMeasure (read DOM)
PluginRuntime.onCompute (pure math)
emit('drag', payload)
PluginRuntime.onCommit (write DOM)
FrameContext.commit()
PluginRuntime.onAfterDrag (scroll, etc.)
License
MIT