Skip to content

Guide

EffDND adds drag & drop to your HTML with just attributes. No JavaScript calls, no setup beyond a single import. This guide explains what you can do with it and how.

Getting started

sh
npm i effdnd

Import once in your app:

ts
import 'effdnd';            // the library
import 'effdnd/style.css';  // optional: default styles

That's it. Any element marked with the attributes below becomes draggable immediately — even elements added to the page later.

Frameworks: works with any of them. No component wrapper is needed.

How it works

EffDND is built around one core idea, and everything else in this guide is just an extension of it:

  • Only a trigger starts a drag. Nothing is draggable on its own. You grab a handle marked with data-dnd-trigger and the drag begins; without one, nothing ever moves.
  • The item is what actually moves. The trigger usually lives inside the item so the whole element follows your pointer, but they do not have to be the same node.
  • The scope is the sandbox. The item can only move and be dropped within its scope. Drop zones or containers outside it are simply ignored.

That is the base case: a trigger on an item inside a scope.

html
<div data-dnd-scope="board">              <!-- scope: the sandbox -->
  <div data-dnd-item="a">                 <!-- item: what moves -->
    <span data-dnd-trigger>⠿</span>     <!-- trigger: what you grab -->
      Grab me
  </div>
</div>

Every feature that follows — reorder, transfer, scrolling, or the trigger parameters below — only refines or extends this core behavior.

Styling while dragging. The element you see moving is a clone (a copy) of the item, inserted into the item's own parent. This means cascaded and inherited styles, and rules written against parent selectors (ul > li, .zone .card, …), still apply to the moving clone — no need to inline every color. The original item stays in place in the list and is dimmed via the passive state (see Styling below).

Caveat — transformed ancestors. The clone is position:fixed, so it is detached from the layout. But if any ancestor of the item has transform, filter, perspective (or will-change:transform), the browser treats fixed like absolute: the clone is then positioned relative to that ancestor and may appear offset (and it will scroll with a scrollable transformed container). Avoid transformed ancestors of dragged items, or override the fixed-visual styles via data-dnd-state and the inline styles you place on the item.

The attributes at a glance

AttributeWhat it does
data-dnd-triggerMarks the "handle" the user drags from
data-dnd-itemMarks the element that gets dragged
data-dnd-scopeLimits drags and drop targets to one container
data-dnd-reorderTurns a container into a sortable list (x or y)
data-dnd-targetMarks a drop zone
data-dnd-transferGives a drop zone an action: append, prepend, or remove
data-dnd-scrollAuto-scrolls the container while dragging
data-dnd-transitionSmooth-out the moving animation
data-dnd-disabledDisables a trigger
data-dnd-stateRuntime state (active/passive) set during a drag — used for styling

Only three of them are required for the most basic case: a handle (trigger) on an element (item) inside a container (scope). Everything else is optional and adds behavior.

Trigger parameters

Because the trigger is the entry point, it is the attribute you tune most often. It accepts a semicolon-separated list of parameters:

html
<span data-dnd-trigger="dist:12;axis:y;scope:board;item:task-1">⠿</span>

Require a drag distance (dist)

Prevents accidental drags on a click — only drags after the pointer moves the given number of pixels:

html
<span data-dnd-trigger="dist:12">⠿</span>   <!-- only drags after the pointer moves 12px -->

Lock to one axis (axis)

html
<span data-dnd-trigger="axis:x">⠿</span>   <!-- horizontal only -->
<span data-dnd-trigger="axis:y">⠿</span>   <!-- vertical only -->

Address a specific container (scope)

By default EffDND finds the nearest scope by looking at the DOM. When you need a specific one, name it:

html
<span data-dnd-trigger="scope:board">⠿</span>

Address a specific item (item)

The trigger can specify which specific item above it to move:

html
<span data-dnd-trigger="scope:board;item:task-1">⠿</span>

Restrict drop targets (target)

Only allow dropping onto zones whose data-dnd-target starts with the given name:

html
<span data-dnd-trigger="scope:board;target:drop">⠿</span>

Move freely across the whole page (scope:*)

The special scope:* value ignores any scope boundary entirely:

html
<span data-dnd-trigger="scope:*">⠿</span>

The item can then be dragged anywhere on the page.

Disable a trigger

html
<span data-dnd-trigger data-dnd-disabled>⠿</span>

Remove the attribute to re-enable.

Typical scenarios

These are the common ways the pieces come together in real interfaces.

Reorder: a sortable list

Wrap your items in a list with data-dnd-reorder="y", mark each item, and add a handle.

html
<ul data-dnd-scope="todo" data-dnd-reorder="y">
  <li data-dnd-item="1"><span data-dnd-trigger>⠿</span>Buy milk</li>
  <li data-dnd-item="2"><span data-dnd-trigger>⠿</span>Read a book</li>
</ul>
  • y sorts vertically, x sorts horizontally.
  • Only items that are direct children of the list are rearranged.

Transfer: move items between containers

Mark a container as a drop zone and give it a transfer action.

html
<div data-dnd-scope="sprint">
  <div class="zone" data-dnd-target="backlog" data-dnd-transfer="append">Backlog</div>
  <div class="zone" data-dnd-target="done" data-dnd-transfer="prepend">Done</div>
</div>
  • append — the item is added to the end of the zone.
  • prepend — the item is added to the start of the zone.
  • remove — the item is deleted (great for a trash bin).

The drop zones must live inside the same scope (or use scope:*, see above).

Combine: a sortable list with a trash bin

html
<div data-dnd-scope="mailbox">
  <ul data-dnd-reorder="y">
    <li data-dnd-item="1"><span data-dnd-trigger>⠿</span>Invoice</li>
    <li data-dnd-item="2"><span data-dnd-trigger>⠿</span>Newsletter</li>
  </ul>
  <button data-dnd-target="trash" data-dnd-transfer="remove">Delete</button>
</div>

Here the list sorts itself and rows can be thrown away by dropping them on the button.

Kanban: columns and cards

Give the board data-dnd-reorder="x" to sort columns, and each column's card area both a vertical reorder zone and a transfer receiver.

html
<div data-dnd-scope="kanban" data-dnd-reorder="x">          <!-- sorts columns -->
  <div class="col" data-dnd-item="col-1">
    <div class="col-head"><span data-dnd-trigger>⠿</span>Backlog</div>
    <div class="cards" data-dnd-reorder="y"
         data-dnd-target="col-1" data-dnd-transfer="append"> <!-- sorts & accepts cards -->
      <div class="card" data-dnd-item="task-1"><span data-dnd-trigger>⠿</span>Write spec</div>
    </div>
  </div>
  <!-- more columns ... -->
</div>

Drag a column header to reorder columns; drag a card to move it between columns.

Tuning common behaviors

Auto-scroll a long container

Attach data-dnd-scroll to the scrollable element:

html
<div data-dnd-scroll="threshold:50;speed:12">
  • threshold (default 30) — how close to an edge scrolling starts, in px.
  • speed (default 10) — how fast it scrolls, in px per frame.

Dragging near an edge now scrolls the container, and scrolling accelerates the closer to the edge you get.

Smooth the animation

html
<div data-dnd-transition="150ms ease">…</div>

Default is 100ms linear.

Reacting to drags from JavaScript

You still can — EffDND just makes it optional. Each function below returns an unsubscribe function:

ts
import { onDrag, onDrop, onReorder, onTransfer, onDragStart, onDragEnd } from 'effdnd';

onReorder((event) => {
  console.log('Item was reordered:', event.detail.keys.item);
});

Available events: effdragstart, effdrag, effdragend, effdragenter, effdragleave, effdrop, effreorder, efftransfer.

Every event gives you a detail object you can rely on:

ts
event.detail.keys.item;          // which item
event.detail.keys.scope;         // in which scope
event.detail.keys.target;        // on which target
event.detail.item;

Helpers you may find useful

ts
import { getItem, getScope, getTargets, getReorderContainer, reset } from 'effdnd';

getItem(trigger);           // the item an element belongs to
getScope(trigger);          // the scope an element belongs to
getTargets(trigger);        // the drop zones available for a trigger
reset(item);                // snap an item back to its original position

Styling with data-dnd-state

During a drag EffDND tags the involved elements with a runtime data-dnd-state attribute. This gives you a clean hook to style the active drag — no inline JavaScript needed. No data-dnd-state is present when nothing is being dragged.

State values

ElementStateMeaning
data-dnd-itemactiveThe moving clone — the element currently following your pointer
data-dnd-itempassiveThe original item left in place, dimmed behind the clone
data-dnd-scopeactiveThe pointer is outside the scope's boundary
data-dnd-scopepassiveThe pointer is inside the scope — normal while dragging
data-dnd-targetactiveThe drop zone currently being hovered
data-dnd-targetpassiveA valid drop zone that is ready to accept a drop

The bundled index.css already ships tasteful defaults built on these selectors, and you use the very same selectors to override them:

css
/* Defaults from index.css */
[data-dnd-item][data-dnd-state="passive"] { opacity: 0.2; }
[data-dnd-item][data-dnd-state="active"]  { opacity: 0.75; z-index: 1000; }

/* Your own theme */
[data-dnd-item][data-dnd-state="passive"] { opacity: 0.08; }
[data-dnd-item][data-dnd-state="active"]  {
    opacity: 1;
    box-shadow: 0 0 0 2px cornflowerblue;
    border-radius: 8px;
}
[data-dnd-scope][data-dnd-state="active"] { outline: 2px dashed tomato; }
[data-dnd-target][data-dnd-state="active"] { background: rgba(100, 200, 255, 0.2); }

Because the defaults live in index.css, loading them is optional: if you skip that import and write your own [data-dnd-state="…"] rules, EffDND stays fully dependency-free and you stay in complete visual control.

Tip. The original is dimmed by opacity, not removed — so the list keeps its layout and siblings don't jump while you drag. If you want the original to disappear entirely, set opacity: 0 (or visibility: hidden) for the passive state.

Mini reference

A condensed version of every attribute and its parameters.

Attribute / parameterValuesDefaultPurpose
data-dnd-triggerMarks the handle that starts a drag
… distnumberDrag only after the pointer moves this many px
… axisx / ybothLock the drag to one axis
… scopename / *nearest in DOMUse a specific container (or ignore scopes)
… itemnamenearest in DOMDrag a specific item
… targetnameallOnly allow drops on targets matching a name
data-dnd-disabledDisables a trigger
data-dnd-itemunique nameMarks the element that gets dragged
data-dnd-scopeunique nameLimits drags/drops to one container
data-dnd-reorderx / yTurns a container into a sortable list
data-dnd-targetnameMarks a drop zone
data-dnd-transferappend / prepend / removeAction taken when dropped on a zone
data-dnd-scrollthreshold;speed30;10Auto-scrolls the container while dragging
data-dnd-transitionduration & easing100ms linearSmooths the moving animation
data-dnd-stateactive / passiveRuntime drag state, styled via CSS (see Styling)

Released under the Apache-2.0 License.