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-trigger——Marks the handle that starts a drag
… distnumber—Drag 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-disabled——Disables a trigger
data-dnd-itemunique name—Marks the element that gets dragged
data-dnd-scopeunique name—Limits drags/drops to one container
data-dnd-reorderx / y—Turns a container into a sortable list
data-dnd-targetname—Marks a drop zone
data-dnd-transferappend / prepend / remove—Action 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 / passive—Runtime drag state, styled via CSS (see Styling)

Released under the Apache-2.0 License.