@mk-drag-and-drop/dom
v0.4.5
Published
Headless DOM drag-and-drop primitives for draggable, droppable, sortable, and grouped interactions.
Maintainers
Readme
@mk-drag-and-drop/dom
@mk-drag-and-drop/dom is the DOM-first behavior package for
mk-drag-and-drop. It handles pointer and keyboard drag input, target
registration and measurement, targeting, lifecycle callbacks, drag overlays,
movement modifiers, sortable previews, and drop-container placement.
The package is headless. Apps own DOM structure, rendering, styling, data, persistence, and final commits. The DOM package reports what happened; it does not own application data.
Import Paths
Use the root package import for application code:
import {
createDragController,
createDraggable,
createDroppable,
createSortable,
} from "@mk-drag-and-drop/dom";The package also exports @mk-drag-and-drop/dom/integration for adapter authors
who need lower-level DOM behavior factories and a runtime scope. Deep imports
into src or dist files are not part of the public package exports.
Public Root API
The root export includes:
createDragControllercreateDraggablecreateDroppablecreateDropContainercreateSortablecreateDragHandle- controller types:
DragController,DragControllerOptions,DragControllerOverlayInput,DragControllerAnnouncements - lifecycle types:
DragStartEvent,DragUpdateEvent,DragEndEvent,DropEvent,DragSource,DragEndResult,DragLifecycleCallbacks,DragLifecycleHelpers - placement types:
SortableDropPlacement,RemeasureDropTargetsInput - overlay types:
OverlayReleaseMode - targeting helpers and types:
pointerToCenter,centerToCenter,pointerToRectDistance,getDistanceToRect,maxPointerDistanceToRect,maxOverlayCenterDistanceToRect,DropTarget,TargetingAlgorithm,TargetingAlgorithmInput,TargetingConstraint,TargetingConstraintInput - modifiers and types:
lockToXAxis,lockToYAxis,restrictToContainer,RestrictToContainerResolver,DragModifier,DragModifierInput,DragModifierSetupInput,DragModifierTransformInput - input and geometry types:
KeyboardCommand,KeyboardConfiguration,PointerConfiguration,DragPoint,DragRect
Internal classes and helpers such as the underlying runtime class, registries, input controllers, measurement helpers, sortable preview internals, and modifier pipeline internals are not public root APIs.
Integration Subpath
@mk-drag-and-drop/dom/integration exports:
createDragRuntimeScopeDragRuntimeScope,DragRuntimeScopeOptions,DragRuntimeScopeConfigureInputDragState,DragOverlayPhase,DragOverlayRenderState,OverlayReleaseModeDragRuntimeSubscriptioncreateDomDraggable,createDomDroppable,createDomDropContainer,createDomSortable- the related DOM behavior/input/runtime types
domDragHandleAttribute
Use this subpath when building a framework adapter or custom behavior layer. For ordinary DOM usage, prefer the root controller and binding helpers.
Controller
createDragController(options?) creates the public DOM drag-and-drop scope used
by the root binding helpers. Controller options include:
- lifecycle callbacks:
onDragStart,onDragUpdate,onDragEnd,onDrop announcements: lifecycle callbacks that return polite live-region textdragOverlay(input): returns an overlayHTMLElementornulloverlayRoot: optional root for the DOM overlay wrapperoverlayRelease:"auto"removes overlays on drag end;"manual"keeps a released overlay until itsremoveOverlaycallback is calledtargetingAlgorithmandtargetingConstraintmodifierspointerConfigurationkeyboardConfiguration
The returned controller has:
remeasureDropTargets(input?): remeasures all targets, one target id, an array of target ids, or{ group }remeasureOverlay(): remeasures the currently mounted drag overlay element; it is a safe no-op when no overlay is mounted or no drag is activerecomputeActiveDrag(): reruns active targeting and drag update from the last pointer position; it is a safe no-op when no drag is active and does not remeasure drop targets
The controller does not expose runtime teardown, disposal, update, or broad overlay removal methods. Runtime objects are ordinary JavaScript objects; DOM bindings and active drag resources are owned by their own lifetimes.
Drag Overlays
dragOverlay(input) is a creation hook. The controller calls it when overlay
content is mounted for the "dragging" phase. By default, overlays are removed
immediately when a drag ends. When overlayRelease: "manual" is configured, the
controller calls dragOverlay once more for the "released" phase and passes
removeOverlay; call that function when app-owned release animation is done.
Pointer movement does not call dragOverlay.
The overlay input includes:
dragState: item id, group, source rect, start pointer, and current pointer.phase:"dragging"or"released".remeasureOverlay: manually refreshes the cached overlay measurement.removeOverlay: present only for"released"overlays in manual release mode; call it when app-owned release animation should remove the overlay.
removeOverlay is only present for released overlays in manual release mode.
Calling it more than once is safe. It removes the overlay host state only; it is
not a controller or runtime teardown API.
Apps own the returned overlay element and any dynamic content inside it. The
package owns the overlay wrapper, movement, release, and geometry measurement
used by targeting and modifiers. The wrapper is positioned with position:
fixed, source rect dimensions, pointer-events: none, and a transform derived
from the current pointer delta.
The controller measures the returned overlay element on mount/replacement. When
ResizeObserver is available, it also remeasures when that element changes
size. The runtime derives the current overlay rect from that cached measurement
plus modified pointer movement, and it does not measure overlay DOM on every
pointer move.
Manual remeasureOverlay() is for cases automatic observation cannot cover or
where the app knows visual geometry changed. CSS transforms can change visual
bounds without necessarily triggering ResizeObserver.
controller.remeasureOverlay();const controller = createDragController({
dragOverlay({ dragState, remeasureOverlay }) {
const element = document.createElement("div");
element.textContent = dragState.draggableId;
element.addEventListener("transitionend", () => {
remeasureOverlay();
});
return element;
},
});For dynamic overlay content, keep a reference to the element and update it from lifecycle callbacks or subscriptions:
let overlayElement: HTMLElement | null = null;
const controller = createDragController({
dragOverlay({ dragState }) {
overlayElement = document.createElement("div");
overlayElement.textContent = dragState.draggableId;
return overlayElement;
},
onDragUpdate({ pointerPosition }) {
if (overlayElement) {
overlayElement.textContent = `${pointerPosition.x}, ${pointerPosition.y}`;
}
},
onDragEnd() {
overlayElement = null;
},
});Lifecycle Events
Lifecycle callbacks receive an event and helper methods.
Event shapes:
type DragStartEvent = {
draggableId: string;
source: DragSource;
pointerPosition: DragPoint;
sourceRect: DragRect;
};
type DragUpdateEvent = {
draggableId: string;
source: DragSource;
pointerPosition: DragPoint;
overlayRect: DragRect | null;
activeDropTargetId: string | null;
previousDropTargetId: string | null;
};
type DragSource = "pointer" | "keyboard";
type DragEndResult =
| "dropped"
| "no-target"
| "invalid-target"
| "canceled";
type DragEndEvent = {
draggableId: string;
source: DragSource;
result: DragEndResult;
dropTargetId: string | null;
overlayRect: DragRect | null;
};
type DropEvent = {
draggableId: string;
source: DragSource;
dropTargetId: string;
sortablePlacement?: SortableDropPlacement;
};
type SortableDropPlacement = {
sourceContainerId: string | null;
containerId: string | null;
previousDraggableId: string | null;
nextDraggableId: string | null;
targetDraggableId: string | null;
side: "before" | "after" | null;
};DragUpdateEvent.overlayRect is the current viewport-space rectangle for the
drag overlay. DragEndEvent.overlayRect is the final viewport-space rectangle
at the moment the drag ends. Both are null when no drag overlay is configured.
When an overlay is configured, the runtime uses cached overlay measurement plus
modified pointer movement. Before overlay content has been measured, it uses the
same translated source-rect fallback as the visual overlay. The drag update and
drag end paths do not read overlay DOM just to produce this field.
Helpers:
getDropTargetRect(dropTargetId)
onDrop only runs for a valid successful drop. onDragEnd runs whenever a drag
ends. result: "dropped" means a valid target was accepted and onDrop also
runs. result: "no-target" means the user ended normally with no active target.
result: "invalid-target" means an active target candidate was stale by drag
end. result: "canceled" means the drag was canceled, such as Escape,
pointercancel, or runtime cancellation.
Bindings
Root binding helpers attach behavior to DOM elements and return void.
Registrations are WeakRef-backed and stale disconnected elements are pruned on
drag-critical paths and explicit remeasurement.
createDraggable(input) accepts:
controllerelementdraggableIdgroup, defaulting to"default"
createDroppable(input) accepts:
controllerelementdropTargetIdgroup, defaulting to"default"containerId, defaulting tonull
createDropContainer(input) accepts:
controllerelementcontainerIdgroup, defaulting to"default"
createSortable(input) accepts:
controllerelementdraggableIdgroup, defaulting to"default"containerId, defaulting tonull
createDragHandle({ element }) marks an element with the drag-handle data
attribute. When a draggable or sortable contains a handle, pointer and keyboard
dragging are handled from that handle instead of unrelated children.
Placement Data
Plain droppable drops are id-only: draggableId, dropTargetId, and source.
Sortable behavior may add sortable-specific placement under
event.sortablePlacement:
type SortableDropPlacement = {
sourceContainerId: string | null;
containerId: string | null;
previousDraggableId: string | null;
nextDraggableId: string | null;
targetDraggableId: string | null;
side: "before" | "after" | null;
};No sortable placement is included for plain droppable drops. No-op sortable drops
may omit sortablePlacement.
targetDraggableId and side preserve the active sortable item anchor when a
drop lands relative to an item. They are null for container-only placements.
Sortable behavior owns only a transient DOM preview during the drag. It may move
DOM nodes to show where an item would land, but it does not commit app data. On
drop, the app reads event.sortablePlacement and updates its own data/rendering.
Targeting
A measured target passed to custom targeting code has:
type DropTarget = {
dropTargetId: string;
dropTargetRect: DragRect;
};Built-in targeting algorithms:
pointerToCentercenterToCenterpointerToRectDistance
Built-in targeting helpers/constraints:
getDistanceToRect(point, rect)maxPointerDistanceToRect({ maxDistance?, maxXDistance?, maxYDistance? })maxOverlayCenterDistanceToRect({ maxDistance?, maxXDistance?, maxYDistance? })
Custom TargetingAlgorithm functions receive pointerPosition, overlayRect,
and the filtered dropTargets list. Algorithms decide which geometry to use:
pointerToCenter and pointerToRectDistance are pointer based, while
centerToCenter computes the overlay center from overlayRect and returns no
target when no overlay rect is available. Custom TargetingConstraint
functions receive pointerPosition, overlayRect, and one dropTarget, and
return whether that candidate is eligible. Use the pointer or overlay-center
distance helper that matches the intended behavior.
Sortable placement is separate from targeting. The configured targeting
algorithm alone chooses activeDropTarget; sortable only decides before/after
preview placement relative to that active target. For sortable groups, the
registry may narrow measured candidates to relevant sortable item, neighbor, or
container entries before the targeting algorithm runs; the algorithm still makes
the active-target decision from that narrowed measured list.
Same-container sortable preview keeps its movement-responsive first-placement
behavior: forward movement places after a newly active target, backward movement
places before, and no sortable-axis movement uses the target midpoint. When a
sortable item first enters a different DOM container, the initial side is based
on the current sortable placement position against the target midpoint on the
sortable axis: above or left of midpoint places before, while below or right of
midpoint places after. Pointer-based targeting uses the pointer as that position;
rect-based targeting such as centerToCenter uses the overlay center.
A container-only preview keeps that cross-container entry state through active recomputes until the first item target in the destination list receives a before/after placement.
placementBoundary is used after a preview placement already has an established
movement direction to control same-target reversal and hysteresis thresholds. It
does not decide the initial side for cross-container sortable entry or delay the
first movement that follows midpoint-based entry into a list.
Modifiers
Modifiers transform pointer movement before drag state updates. Built-ins:
lockToXAxis()lockToYAxis()restrictToContainer(resolver)
restrictToContainer receives a resolver function with
DragModifierSetupInput and returns an HTMLElement | null. The resolver runs
at drag setup to choose the bounds element; the selected element's rect is
remeasured during movement so growing or shrinking containers keep constraining
against current bounds.
Custom modifiers can provide:
setup(input): optional setup state for the active dragtransform(input): returns the nextDragPoint
Modifier input includes draggableId, group, source rect, initial pointer
position, raw pointer position, current pointer position, overlay rect, and
modifier state.
Measurement
Targets are measured when registered and remeasured at drag start. The explicit
target remeasurement entry point is controller.remeasureDropTargets(input?);
call it when app-owned layout changes during a drag should affect targeting.
Overlay measurement is separate from target measurement. Overlay content is
measured on mount/replacement and by ResizeObserver when available. Use
controller.remeasureOverlay() when the current overlay geometry should be
refreshed manually, such as after an app-owned transition or transform that
changes visual bounds without a normal size-observer notification.
RemeasureDropTargetsInput accepts:
- no argument for all targets
- a target id string
- an array of target id strings
{ group: string }
Sortable preview movement does not automatically remeasure a group. Targeting continues from the runtime's cached target rects until the app explicitly remeasures. Final sortable placement is derived from the current preview DOM order, so app-owned layout changes such as expanding tree rows or changing grouped sections during a drag should call the public remeasure API when those changes need to affect targeting.
Active Drag Recompute
controller.recomputeActiveDrag() reruns the active drag update path from the
last stored raw pointer position. It reapplies modifiers, derives the overlay
rect from cached overlay measurement, recomputes the active drop target from
cached target measurements, updates the overlay host, and sends the normal drag
update notifications. This is the same update path used by pointer movement.
Calling it with no active drag is safe and does nothing.
Use it when something external changes what is under the pointer during an active drag without producing a new pointer event.
controller.recomputeActiveDrag();Recompute does not remeasure drop targets. If target geometry or registrations changed and those changes should affect targeting, remeasure first and then recompute.
controller.remeasureDropTargets({ group: "items" });
controller.recomputeActiveDrag();// Example: after external scrolling changes what is under the pointer
controller.recomputeActiveDrag();Data Ownership
The DOM package never owns application data. Item ids, target ids, group ids, and container ids are identifiers that your app maps to its own model.
Commit data in lifecycle callbacks, usually onDrop. Rerender or patch the DOM
using your app's normal rendering path after the commit.
Lifetime Model
Root binding helpers do not return per-item disposers. Runtime/controller objects are ordinary JavaScript objects and are released by garbage collection when unreachable.
DOM registrations are a separate lifetime. Bindings keep WeakRef-backed records, and disconnected drop targets/sortables are pruned on drag-critical paths and explicit remeasurement. Active drag resources are owned by the input drag lifecycle and are released on drop, cancel, and pointercancel.
Pending pointer activation is also separate from an active drag. Activation delay timers, the pending activation state, and the latest pre-activation pointer position are canceled by pointerup, pointercancel, activation success, activation failure, or active drag reset.
Active drag resources include pointer/window listeners, keyboard listeners, RAF pointer updates, text-selection suppression, active target/session state, overlay host state, active modifiers, and sortable preview/snapshot state. They are released by active drag lifecycle paths. Runtime disposal is not the cleanup mechanism for those resources.
Sortable registration state and sortable active preview state are separate. Registered sortable elements, ids, groups, containers, and options remain until they are unregistered or pruned as stale DOM. Per-drag sortable snapshots, dragged attributes, preview placement, and moved DOM are restored by active drag cleanup.
Accessibility Scope
Keyboard dragging is available when keyboardConfiguration.enabled is not
disabled. Defaults use Space or Enter to start/drop, Escape to cancel, arrow
keys to move, and a default movement distance of 24.
Draggable or sortable elements may receive tabIndex and keyboard handlers
when keyboard dragging is enabled. Drag handles and focusable draggable elements
should have clear labels.
Announcements are opt-in through announcements. The controller creates a
polite live region only when announcement callbacks are provided. The package
does not claim complete screen-reader drag-and-drop support; apps should provide
domain-specific instructions and announcements for their interaction model.
The lifecycle onDragUpdate callback runs for drag updates; the announcement
onDragUpdate callback is intended for active-drop-target transitions and
dedupes repeated messages to avoid live-region spam. Use lifecycle
onDragUpdate for per-frame side effects, not announcements.
Example
import {
createDragController,
createDraggable,
createDroppable,
} from "@mk-drag-and-drop/dom";
const state = {
itemLocation: "todo",
};
const controller = createDragController({
onDrop({ draggableId, dropTargetId }) {
state.itemLocation = dropTargetId;
console.log(`${draggableId} moved to ${dropTargetId}`);
},
});
const item = document.createElement("button");
item.type = "button";
item.textContent = "Drag me";
createDraggable({
controller,
element: item,
draggableId: "task-1",
});
for (const columnId of ["todo", "done"]) {
const column = document.createElement("section");
createDroppable({
controller,
element: column,
dropTargetId: columnId,
});
document.body.append(column);
}
document.body.append(item);Examples
Vanilla DOM examples live in apps/web/src/vanilla:
basicDrag.tsdropzoneList.tssortableList.tskanbanExample.tsgroupedExample.tstreeExample.ts
They use root imports from @mk-drag-and-drop/dom.
Development
pnpm --filter @mk-drag-and-drop/dom build
pnpm --filter @mk-drag-and-drop/dom lint
pnpm --filter @mk-drag-and-drop/dom testExample app verification:
pnpm --filter web build