@ptahjs/dnd
v0.0.3
Published
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.
Readme
@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/dndQuick 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
└── TransformControllerServicePer-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
