drag-droppable
| Install | |
|---|---|
npm install drag-droppable |
|
| Latest Version: | 0.1.2 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Oct 27, 2026 |
| Links: | GitHub · npm |
Drag-droppable
Lightweight, data-driven, framework-independent drag-and-drop system with touch support.
Attach data to draggable and droppable elements and use it to handle the drops. Drop targets react only to relevant drags based on namespace, type and respective data.
You decide how to represent the drag-and-drop interactions... with a lot of information to do so! The library doesn't include any built-in styling that would get in the way (it would, it always does).
You decide how to handle drops... with a lot of information to do so! No HTML mutation will actually move anything around permanently, preventing inconsistencies with the actual state of your app (it would, it always does).
You choose your favorite framework. Although the library has been developped with Svelte in mind, it will work fine in React, Vue and even plain old Javascript. Optional React hooks and Vue composables are even provided!
You can use it with touch support out of the box without any adapter or workaround... and you can configure it as you like.
The core has no dependencies, only a few kB of modern Javascript. Optional React hooks and Vue composables use your application's framework installation.
Table of contents
- Table of contents
- Typical usage
- Data shape
- Drag:
dndDrag - Drop:
dndDrop - Drag and drop:
dnd - Observing changes
- Dynamic configuration
- Global configuration
- Data attributes
- Touch
- Lifecycle
- Limitations
- Usage in Svelte (optional)
- Usage in React (optional)
- Usage in Vue (optional)
- Examples
- TypeScript
- Advanced: stores and custom integrations
Typical usage
The following example showcases a baby (a drop target element) and a variety of things it could bring to its mouth (dragged elements). Data involved in the process allow deciding what can go in the mouth or not. Styling these interactions is left to the app code. No HTML mutation will actually move anything around permanently.
import { dndDrag, dndDrop } from "drag-droppable";
/* All HTML elements in the example are made available based on the runtime
* (Svelte, React, plain JS, etc.) */
/* Droppable element */
let babySleeps = false;
dndDrop(babyMouthEl, {
/* Accepted drag types */
types: ["food"],
/* Drop can be completely disabled */
disabled: babySleeps,
/* Drop can be accepted conditionally based on dragged element */
when: ({ drag: food }) => food.tastes.includes("sweet"),
unless: ({ drag: food }) => food.texture === "solid",
/* Do something after drop */
onDrop({ drag: food }) {
eatFood(food);
},
});
/* Draggable elements */
/* Rejected: wrong taste and texture */
dndDrag(saltyFoodEl, {
type: "food",
data: { tastes: ["salty"], texture: "solid" },
});
/* Accepted! Miam! */
dndDrag(pureeEl, {
type: "food",
data: { tastes: ["sweet"], texture: "smooth" },
});
/* Rejected: wrong type */
dndDrag(toyEl, {
type: "toy",
data: { color: "red" },
});
Data shape
The same data shape is used to allow (when), deny (unless) or react to a drop (onDrop):
onDrop(
({
drag, // Dragged data.
drop, // Drop data.
namespace, // Namespace of drag-and-drop.
type, // Dragged element type.
/* Position */
x, // x position on drop element, in px.
y, // y position on drop element, in px.
dx, // x position on drop element, in ratio between 0 and 1.
dy, // y position on drop element, in ratio between 0 and 1.
left, // Within the configured left zone.
right, // Within the configured right zone.
top, // Within the configured top zone.
bottom, // Within the configured bottom zone.
}) => {},
);
An onDrop callback will be called when a drop successfully occurs, either from the perspective of:
- the dragged then dropped element (draggable);
- the element that received the dropped element (droppable).
In both cases, the function receives the same arguments. With the combined dnd binding, onDrop belongs only to the target.
Drag: dndDrag
An element can be declared draggable with dndDrag.
import { dndDrag } from "drag-droppable";
const {
destroy, // ()
update, // (newConfiguration)
} = dndDrag(HTMLElement, {
/* Most relevant */
namespace,
type,
data,
disabled,
/* Handling */
onChange,
onDrag,
onDragEnd,
onDrop,
/* Configuration */
attributesPrefix,
dragEl,
effectAllowed,
except,
handle,
touch,
});
Most relevant
namespace: Only draggable and droppable in the same namespace can interact with each other. null and undefined namespace is treated as "". Optional.
type: Type of draggable element. Only droppables that include it in their types will react to it.
data: Data the dragged element carries with it. Available as drag in the single argument of unless, when, and onDrop, and in the target state reported by onChange.
disabled: Disabled draggable element cannot be dragged.
Handling
onChange(state): React to changes in dragging and pointerType. See Observing changes.
onDrag(data, type, namespace): Function to execute when a drag is initiated.
onDragEnd(data, type, namespace): Function to execute when a drag ends, whether it resulted in a successful drop or was cancelled. Receives the data, type and namespace captured when the drag started. Use onDrop for successful drops and onDragEnd for end-of-drag cleanup.
onDrop({ ...onDropArgs }): Function to execute when dragged element is dropped. See Data shape.
Configuration
attributesPrefix: Data attributes prefix to use. See Data attributes.
dragEl: HTML element to use as a drag image. Defaults to the bound element.
effectAllowed: HTML5 drag dataTransfer.effectAllowed configuration.
except: What to ignore when initiating a drag. Element, CSS selector or function.
handle: HTML element used to initiate drag. Defaults to the bound element.
touch: Touch configuration local override. See Touch.
Returns
destroy(): A function to destroy the draggable functionalities and remove handlers.
update(configOrFunction): Replace the draggable configuration, directly or through an updater function. See Dynamic configuration.
Drop: dndDrop
An element can be declared as a potential dropzone with dndDrop.
import { dndDrop } from "drag-droppable";
const {
destroy, // ()
update, // (newConfiguration)
} = dndDrop(HTMLElement, {
/* Most relevant */
namespace,
types,
type,
data,
disabled,
/* Handling */
onChange,
onDrop,
unless,
when,
/* Configuration */
attributesPrefix,
bottomTopThreshold,
leftRightThreshold,
target,
});
Most relevant
namespace: Only draggable and droppable in the same namespace can interact with each other. null and undefined namespace is treated as "". Optional.
types: Types of draggable elements accepted by the droppable.
type: Shorthand when accepting a single type.
data: Data associated with the drop element. Available as drop in the single argument of when and unless, in the onDrop callback argument, and in the state reported by onChange.
disabled: Disabled droppable element cannot accept drops.
Handling
onDrop({ ...onDropArgs }): Function to execute when dragged element is dropped. See Data shape.
onChange(state): React to changes in accepts, concerned, and drop position. See Observing changes.
unless({ drag, drop, type, namespace, ...position }): Predicate function that prevents drop when true.
when({ drag, drop, type, namespace, ...position }): Predicate function that allows drop only when true.
Both receive one object containing { drag, drop, type, namespace, ...position }. Position fields (x, y, dx, dy, left, right, top, bottom) are available during hover and drop when the target has a usable rectangle. For example:
when: ({ drag: food, drop: baby }) =>
baby.hunger > 0 && food.texture === "smooth";
For matching types and namespaces, these conditions are checked when a drag starts and when a target is added or its options change. As you move over targets, only the hovered target is checked again, including targets that previously refused the drag. Conditions are checked once more before a drop. Position fields are absent outside hover and drop checks, so allow for missing values if your conditions use them.
Each of them can also be defined as an object with a different handler for each type.
import { dndDrop } from "drag-droppable";
dndDrop(HTMLElement, {
types: ["food", "toy"],
when: {
food: ({ drag: food }) => food.texture === "smooth",
toy: ({ drag: toy }) => toy.size < 42,
},
onDrop: {
food: ({ drag }) => eatFood(drag),
toy: spitOut, // Receives complete dnd data, including position
},
});
Configuration
attributesPrefix: Data attributes prefix to use. See Data attributes.
target: Which element to use as drop target. Element or CSS selector.
bottomTopThreshold and leftRightThreshold: Override the global position thresholds for this droppable. Omitted or undefined values inherit the global settings; zero disables the corresponding axis.
import { dndDrop } from "drag-droppable";
dndDrop(babyMouthEl, {
type: "food",
bottomTopThreshold: 0.2,
leftRightThreshold: 0 /* Dinner has no left or right wing. */,
onDrop: feedBaby,
});
Returns
destroy(): A function to destroy the droppable functionalities and remove handlers.
update(configOrFunction): Replace the droppable configuration, directly or through an updater function. See Dynamic configuration.
Drag and drop: dnd
Use dnd(element, options) when the same element is both a drag source and a drop target for the same namespace and type. It also works as a Svelte action:
<script>
import { dnd } from "drag-droppable";
</script>
<div
use:dnd={{
type: "baby",
data: { name: "Mia" },
onDrop: ({ drag, drop }) => console.log(drag, "visits", drop),
}}
>
Baby Mia
</div>
The flat configuration combines draggable and droppable options. data, namespace, disabled, and attributesPrefix apply to both roles. type defines the emitted type and, by default, the accepted type. Set types to override accepted types independently: { type: "baby", types: ["baby", "toy"] } emits "baby" and accepts "baby" or "toy".
onDrop handles drops only, including callbacks defined by type. onDrag and onDragEnd keep their usual source behavior.
dnd rejects drops onto the same element by default. Set allowSelfDrop: true to permit them.
dnd returns update() and destroy(), like dndDrag and dndDrop. disabled disables both roles. Its onChange callback reports both roles together: dragging describes this element being dragged, while accepts and position describe a drag over this element.
For different data or namespaces for each role, or a source-side onDrop, use dndDrag and dndDrop separately.
Observing changes
Use onChange to update your interface as a drag progresses. You can also style elements with data attributes, without a callback.
<script>
import { dndDrop } from "drag-droppable";
let drop = $state({});
</script>
<div
use:dndDrop={{ type: "food", onChange: (state) => (drop = state) }}
class:highlight={drop.accepts}
>
Drop food here
</div>
The callback runs immediately when added, then whenever the state changes. Replacing the callback does not call it again unless the state also changes, so functions written directly in the options work fine. Removing and later adding onChange triggers a new immediate call.
| State field | Meaning |
|---|---|
dragging |
For dndDrag and dnd, this element is being dragged. For dndDrop, a matching drag is in progress. |
pointerType |
The source pointer type, such as "mouse" or "touch", when available. |
concerned |
The target accepts the current drag's type, namespace, and conditions. |
unconcerned |
Type and namespace match, but conditions prevent a drop. |
accepts |
The drag is over this target and a drop is allowed. |
drag, drop, type, namespace |
Data and identity of the current drag and target. |
x, y, dx, dy, left, right, top, bottom |
Position over the target; see Data shape. |
Source state contains dragging and pointerType; target state contains the acceptance, data, and position fields. dnd combines them. Target position clears on leave or drop, and drag state resets when the drag ends or the element is detached. Treat the received state as read-only.
React and Vue provide reactive state directly through their hooks and composables. They also accept onChange if you prefer a callback.
Dynamic configuration
The argument used for initial configuration might change at runtime. For example, having a disabled draggable can be useful, but it probably should be based on a condition that could change.
dnd, dndDrag, and dndDrop return an update() method.
An object argument replaces the entire configuration; omitted options are not preserved.
An updater function receives a shallow copy of the current configuration and must return the replacement. Spread the current configuration to retain options you do not want to change. Nested objects such as data are shared, so copy them too if you want to change them without changing the original.
Passing or returning a falsy value resets the configuration to { disabled: true }; it does not preserve options for later reactivation.
const baby = dndDrop(babyMouthEl, {
type: "food",
data: { name: "Mia", hunger: 6 },
onDrop: feedBaby,
});
/* Replace all options. */
baby.update({
type: "food",
data: { name: "Mia", hunger: 3 },
onDrop: feedBaby,
});
/* Preserve the other options. The baby needs a nap. */
baby.update((current) => ({ ...current, disabled: true }));
/* Replace only the data, keeping the other options. */
baby.update((current) => ({
...current,
data: { ...current.data, hunger: 6 },
}));
In Svelte, pass reactive options to use: and configuration updates automatically:
<script>
import { dndDrag, dndDrop } from "drag-droppable";
let { babySleeps } = $props();
</script>
<div use:dndDrag={{ type: "toy" }}>Red Truck Toy</div>
<div
use:dndDrop={{
types: ["food", "toy"],
disabled: babySleeps /* Updates reactively. */,
}}
>
Baby Mouth
</div>
Targets added or re-enabled during a drag can participate immediately. Updating options keeps the hover position while the same target still accepts the drag. Changing the target element or refusing the drag clears its hover state; hover the target again before dropping.
Global configuration
No configuration is required to get started. Use configureDnD() to change defaults shared by the library:
import { configureDnD } from "drag-droppable";
configureDnD({
/* Default values */
attributesPrefix: "dnd",
bottomTopThreshold: 0.5,
leftRightThreshold: 0.5,
touch: true,
});
attributesPrefix sets the prefix for data attributes. For example, "dd" produces data-dd-*; a falsy value disables these attributes by default. Individual draggable and droppable options can override the prefix.
touch controls touch drag-and-drop defaults. See Touch for its options and local override rules.
Repeated calls preserve omitted non-touch settings. Touch settings revert to their defaults before applying the supplied touch configuration.
Position thresholds
bottomTopThreshold: Height fraction occupied by each top/bottom edge zone.
leftRightThreshold: Width fraction occupied by each left/right edge zone.
Both default to 0.5, splitting the target in halves. A threshold of 0.2 creates two 20% edge zones and a neutral 60% center where neither flag on that axis is true. Zero or a negative value disables both flags on the axis. Values above 0.5 are capped at 0.5, so opposite zones never overlap.
For example, with leftRightThreshold: 0.2, left is true when dx < 0.2 and right when dx >= 0.8. Top/bottom use the same rules with dy. At the default midpoint, the exact center belongs to right/bottom.
configureDnD({
bottomTopThreshold: 0.2,
leftRightThreshold: 0.2,
});
These defaults apply to droppables unless they specify a local override. Thresholds only affect the left, right, top, and bottom flags and their data attributes. Drops can still be accepted in the neutral center.
Data attributes
The library doesn't include any styling, only data that can be used to apply styles. To simplify this process, some properties are attached to the bound elements using data attributes. By default, these attributes start with data-dnd-. Available attributes are:
Draggable
data-dnd-draggable-namespace: Draggable namespacedata-dnd-draggable-type: Draggable typedata-dnd-draggable: Present when element is active draggabledata-dnd-dragging: Present when element is being dragged
Droppable
data-dnd-droppable-namespace: Droppable namespacedata-dnd-dragged-type: Dragged element typedata-dnd-droppable: Present when element is active droppable with at least one typedata-dnd-concerned: Present when dragged element is relevantdata-dnd-unconcerned: Present when potentially relevant dragged element is rejected byunlessorwhenpredicatesdata-dnd-accepts: Present when dragged element is relevant and is over droppable elementdata-dnd-top/data-dnd-bottom: Present during an accepted hover within the configured top/bottom zone.data-dnd-left/data-dnd-right: Present during an accepted hover within the configured left/right zone.
Position attributes follow the position thresholds and are removed outside the corresponding zones or when the hover ends.
Boolean attributes are either present or not. CSS can then easily target these attributes.
/* Draggable element is being dragged */
[data-dnd-draggable][data-dnd-dragging] {
outline: solid 2px black;
}
/* Droppable element is concerned */
[data-dnd-droppable][data-dnd-concerned] {
outline: dashed 2px #0e7490;
}
/* Droppable element can accept drop */
[data-dnd-droppable][data-dnd-accepts] {
background-color: #67e8f9;
}
/* Droppable element could have been concerned, but refuses it at runtime. */
[data-dnd-droppable][data-dnd-unconcerned] {
outline: solid 1px red;
}
In Svelte component styles, wrap the attribute selector in :global() and combine it with a local class:
.item:global([data-dnd-draggable]) {
/* Some CSS rules */
}
The prefix can be changed globally or overridden on individual elements. See Global configuration.
Touch
Touch dragging works out of the box. By default, hold briefly before moving an element; this helps distinguish dragging from scrolling. Adjust the delay, movement tolerance, and other settings with configureDnD:
import { configureDnD } from "drag-droppable";
configureDnD({
touch: {
/* Default values */
autoScroll: true, // Scroll near the edges during touch drags.
scrollSensitivity: 40, // Edge activation zone, in pixels.
scrollSpeed: 1000, // Maximum scroll speed, in pixels per second.
dragImageOpacity: 0.5, // Opacity of the image created to follow the touch.
lift: true, // Visual cue when touch dragging starts.
moveThreshold: 5, // Minimum movement in pixels to consider a drag valid.
pressHold: 300, // Number of milliseconds to statically hold before initiating drag.
pressHoldMargin: 5, // Tolerance in pixels while holding. Moving more cancels drag.
vibration: 10, // Vibration pattern used in `navigator.vibrate`.
},
});
Only some properties can be set; they will get merged with the default one.
import { configureDnD } from "drag-droppable";
configureDnD({
touch: {
vibration: [20, 10, 20],
/* All other values revert to defaults */
},
});
Setting touch to false in the global configuration will disable all touch drag-and-drop behaviours. Non-touch drag-and-drop will still be possible.
Setting touch to null in the global configuration will by default disable touch drag-and-drop behaviours, but individual draggable configurations can activate it locally. Non-touch drag-and-drop will still be possible.
Setting touch to true or undefined in the global configuration will reset the default values altogether.
Automatic scrolling
During a touch drag, holding the finger near the edge of a scrollable container or the viewport scrolls the content automatically. Scrolling becomes faster closer to the edge and continues even when the finger stays still. The nearest scrollable ancestor takes priority on each axis; an ancestor can take over when it reaches its limit.
Use scrollSensitivity to change the edge zone and scrollSpeed to change the maximum speed. Set autoScroll: false to disable it globally under touch, or locally on a draggable:
dndDrag(foodEl, {
type: "food",
touch: { autoScroll: false },
});
The target under the finger stays updated while the content scrolls. Scrolling stops when the finger leaves the edge zone or the drag ends or is cancelled. These scrolling options apply to touch dragging. Mouse scrolling follows the browser's usual behavior.
Local touch override
Set touch on an individual draggable to override the global touch settings for that element. You can use the same options shown above.
Omit touch or set it to undefined to use global settings. Set it to false or null to disable touch dragging on that element.
Lifecycle
In JavaScript, call the returned destroy() when an element no longer needs drag-and-drop behavior:
const binding = dndDrop(element, { type: "food" });
// When removing the element:
binding.destroy();
Svelte actions, React hooks, and Vue composables handle cleanup automatically. See Advanced: stores and custom integrations if you manage controllers yourself.
Limitations
- Keyboard interactions are not supported for the moment.
Usage in Svelte (optional)
Although the library is framework-independant, it has been developed by a Svelte programmer.
Instead of getting a reference to an HTML element to pass as argument, elements can be targeted with the use: directive.
<div use:dndDrag={{ type: "toy", data: { color: "red" } }}>Red Truck Toy</div>
Pass reactive options directly to the action. Use onChange when the template needs drag state, as shown in Observing changes. Svelte handles option updates and cleanup.
Usage in React (optional)
React 18 and 19 applications can import hooks from drag-droppable/react. React is an optional peer dependency; using the core entry point does not require it.
Each hook returns ref and state. Attach ref to one HTML element; state updates as you drag. Options and cleanup are handled automatically. An advanced controller is also available; see Stores and custom integrations.
import { useState } from "react";
import { useDraggable, useDroppable } from "drag-droppable/react";
export default function Dinner() {
const [hunger, setHunger] = useState(6);
const [spoonfuls, setSpoonfuls] = useState(4);
const { ref: mealRef } = useDraggable({
type: "food",
data: { quality: 2 },
disabled: spoonfuls <= 0,
});
const { ref: babyRef, state } = useDroppable({
type: "food",
data: { hunger },
when: ({ drop }) => drop.hunger > 0,
onDrop: ({ drag: meal }) => {
setHunger((current) => Math.max(0, current - meal.quality));
setSpoonfuls((current) => current - 1);
},
});
return (
<>
<div ref={mealRef}>Dinner: {spoonfuls} spoonfuls left</div>
<div ref={babyRef} className={state.accepts ? "open-wide" : ""}>
Mia: {hunger > 0 ? `Hunger: ${hunger}` : "Finally full."}
</div>
</>
);
}
state contains the fields described in Observing changes. Read them to render your UI; use React state setters to change your application data. Options, callbacks, and data objects can be written directly in the hook call.
If you only need CSS attributes or callbacks, skip automatic state updates:
const { ref } = useDroppable(options, { subscribe: false });
With subscribe: false, the returned state stays at its initial value. Data attributes and callbacks, including onChange, still work.
Pass options to the hook and use ref to attach the element. Server rendering starts with idle state; dragging becomes available in the browser.
For TypeScript, specify local data first and incoming data second, for example useDroppable<Baby, Food>(options).
Usage in Vue (optional)
Vue 3.3+ applications can import composables from drag-droppable/vue. Vue is an optional peer dependency; the core entry point does not import it.
Call useDraggable and useDroppable in component setup. Each returns an element ref and a reactive state ref. An advanced controller is also available; see Stores and custom integrations.
<script setup>
import { ref } from "vue";
import { useDraggable, useDroppable } from "drag-droppable/vue";
const hunger = ref(6);
const spoonfuls = ref(4);
const { ref: mealRef } = useDraggable(() => ({
type: "food",
data: { quality: 2 },
disabled: spoonfuls.value <= 0,
}));
const { ref: babyRef, state: dropState } = useDroppable(() => ({
type: "food",
data: { hunger: hunger.value },
when: ({ drop }) => drop.hunger > 0,
onDrop: ({ drag: food }) => {
hunger.value = Math.max(0, hunger.value - food.quality);
spoonfuls.value -= 1;
},
}));
</script>
<template>
<div ref="mealRef">Dinner: {{ spoonfuls }} spoonfuls left</div>
<div ref="babyRef" :class="{ 'open-wide': dropState.accepts }">
Mia: hunger {{ hunger }}
</div>
</template>
Use a getter (() => ({ ... }))) when options depend on reactive values. The composable updates the configuration whenever those values change. A plain options object or a ref containing options also works.
Read state as dropState.value.accepts in scripts or dropState.accepts in templates. Treat it as read-only and change application data through Vue's reactivity.
If you only need CSS attributes or callbacks, skip automatic state updates:
const { ref: babyRef } = useDroppable(() => options, { subscribe: false });
The returned state then stays at its initial value, while callbacks, including onChange, and data attributes still work. You can change this setting reactively too.
The composable follows element changes and handles cleanup when the component unmounts. Pass configuration through its options and attach the element using its ref. Server rendering starts with idle state.
Type parameters follow the same convention as the core and React APIs: local data first, partner data second, for example useDroppable<Baby, Food>(() => options).
Examples
Drag position
Who gets fed first?
Use top and bottom to show an insertion line, then use bottom in onDrop to insert before or after the target. The library reports the position; the app code changes the order.
In this Svelte example, each row can be moved and uses onChange to show the current drop position.
<!-- QueueBaby.svelte -->
<script>
import { dnd } from "drag-droppable";
let { baby, onMove } = $props();
let position = $state({});
</script>
<div
use:dnd={{
type: "baby",
data: baby,
onDrop: onMove,
onChange: (state) => (position = state),
}}
class:before={position.accepts && position.top}
class:after={position.accepts && position.bottom}
>
{baby}
</div>
<style>
div {
padding: 1rem;
}
.before {
box-shadow: inset 0 2px #0e7490;
}
.after {
box-shadow: inset 0 -2px #0e7490;
}
</style>
The parent updates the list, keeping each baby in a keyed row:
<!-- Queue.svelte -->
<script>
import QueueBaby from "./QueueBaby.svelte";
let babies = $state(["Mia", "Leo", "Zoe"]);
function moveBaby({ bottom, drag, drop }) {
const next = babies.filter((baby) => baby !== drag);
const index = next.indexOf(drop);
next.splice(index + (bottom ? 1 : 0), 0, drag);
babies = next;
}
</script>
{#each babies as baby (baby)}
<QueueBaby {baby} onMove={moveBaby} />
{/each}
Using namespaces
Different nurseries, same toys
Both nurseries accept "toy", but a matching namespace is also required. The upstairs duck stays upstairs. It has a very strict contract.
import { dndDrag, dndDrop } from "drag-droppable";
for (const nursery of ["upstairs", "downstairs"]) {
dndDrag(toyEls[nursery], {
namespace: nursery,
type: "toy",
data: { name: "Squeaky duck" },
});
dndDrop(basketEls[nursery], {
namespace: nursery,
type: "toy",
onDrop: ({ drag: toy }) => putAway(nursery, toy),
});
}
Omitting the namespace uses the common default group; it does not match every namespace. Outside Svelte actions, retain the returned destroy() functions for cleanup as described in Lifecycle.
Data-driven decisions
The two sides of the spoon
The dragged data describes the food; the drop data describes the babies. The callback can use both to decide how much is served to which baby.
import { dndDrag, dndDrop } from "drag-droppable";
const babyByName = Object.fromEntries(
[
{ el: miaEl, name: "Mia", hunger: 6 },
{ el: leoEl, name: "Leo", hunger: 3 },
].map(({ el, ...data }) => {
const drop = dndDrop(el, {
data,
type: "food",
when: ({ drop: { hunger } }) => hunger > 0,
onDrop: feedBaby,
});
return [data.name, drop];
}),
);
const mealsByName = Object.fromEntries(
[
{ el: goodMealEl, name: "good", quality: 2, spoonfuls: 4 },
{ el: badMealEl, name: "bad", quality: 1, spoonfuls: 10 },
].map(({ el, ...data }) => {
const drag = dndDrag(el, {
data,
type: "food",
disabled: !data?.spoonfuls,
});
return [data.name, drag];
}),
);
/* Both `drag` and `drop` data are used in the `onDrop` callback.
* Note that callback could have been placed on either `dndDrag` or `dndDrop`. */
function feedBaby({
/* `draggedData` gives information about food quality */
drag: { name: mealName, quality = 0 },
/* `drop` identifies which baby to update */
drop: { name: babyName },
}) {
const meal = mealsByName[mealName];
if (meal) {
meal.update(({ data: { spoonfuls, ...restData } = {}, ...rest }) => ({
...rest,
data: { ...restData, spoonfuls: spoonfuls - 1 },
disabled: spoonfuls - 1 <= 0,
}));
}
const baby = babyByName[babyName];
if (baby) {
baby.update(({ data, ...rest }) => ({
...rest,
data: { ...data, hunger: data.hunger - quality },
}));
}
}
feedBaby belongs to your application; the library doesn't impose any special way to manage interactions. It just provides the infrastructure to simplify data sharing.
With Svelte, the same example can use reactive data directly. No controller lookup is needed: drag and drop refer to the meal and baby objects from $state.
<script>
import { dndDrag, dndDrop } from "drag-droppable";
let babies = $state([
{ name: "Mia", hunger: 6 },
{ name: "Leo", hunger: 3 },
]);
let meals = $state([
{ name: "good", quality: 2, spoonfuls: 4 },
{ name: "bad", quality: 1, spoonfuls: 10 },
]);
function feedBaby({ drag: meal, drop: baby }) {
meal.spoonfuls -= 1;
baby.hunger = Math.max(0, baby.hunger - meal.quality);
}
</script>
{#each meals as meal (meal.name)}
<div
use:dndDrag={{
type: "food",
data: meal,
disabled: meal.spoonfuls <= 0,
}}
>
{meal.name} food: {meal.spoonfuls} spoonfuls left
</div>
{/each}
{#each babies as baby (baby.name)}
<div
use:dndDrop={{
type: "food",
data: baby,
when: ({ drop }) => drop.hunger > 0,
onDrop: feedBaby,
}}
>
{baby.name}: {baby.hunger > 0
? `Hunger: ${baby.hunger}`
: "Full. Ask again in five minutes."}
</div>
{/each}
Svelte reacts to these mutations, updates the displayed values, and passes updated options to the actions. An empty meal becomes disabled automatically. The application changes the data; the library still only passes it along.
Target and handle
Move the plate, aim for the mouth
handle chooses where a drag can start, except protects an interactive control inside that handle, and target limits where a drop can land. Dropping dinner on the baby's forehead does not count.
<script>
import { dndDrag, dndDrop } from "drag-droppable";
let tastings = $state(0);
let lastMeal = $state("Nothing yet");
</script>
<article
use:dndDrag={{
type: "food",
data: { name: "Pea puree" },
handle: ".handle",
except: "button",
}}
>
<span class="handle">
Move plate
<button type="button" onclick={() => tastings++}>Taste first</button>
</span>
<p>Pea puree. Tastings: {tastings}.</p>
</article>
<article
use:dndDrop={{
type: "food",
target: ".mouth",
onDrop: ({ drag: food }) => (lastMeal = food.name),
}}
>
<h3>Mia</h3>
<div class="mouth">Feed here</div>
<p>Last meal: {lastMeal}</p>
</article>
<style>
.handle {
cursor: grab;
}
.mouth {
padding: 1rem;
border: 1px dashed gray;
}
</style>
The whole plate is used as the drag image by default, even though only its handle starts the drag. Drop positions are measured relative to the mouth, while data attributes remain attached to the article.
TypeScript
Types are included. Data types are inferred from data. To type both sides of a drop, specify this element's data first and the other side's data second:
import { dndDrop } from "drag-droppable";
type Baby = { name: string; hunger: number };
type Food = { name: string; quality: number };
const baby = dndDrop<Baby, Food>(babyEl, {
type: "food",
data: { name: "Mia", hunger: 6 },
when: ({ drag: food, drop }) => drop.hunger > 0 && food.quality > 0,
onDrop: ({ drag: food, drop }) => {
drop.hunger = Math.max(0, drop.hunger - food.quality);
},
});
The same order applies to dndDrag and dnd. With dnd, the first type describes the data shared by both roles and the second describes incoming drag data. React and Vue use this convention too. Unspecified types default to unknown; keep types and namespaces consistent between sources and targets.
Advanced: stores and custom integrations
For most applications, use the actions above with onChange, or the React and Vue integrations. Separate controllers are also available when you need manual subscriptions, a reusable controller, or an integration with another framework.
These controllers follow the Svelte store contract and can be created before attaching an element:
| Factory | Corresponding action |
|---|---|
draggable(options) |
dndDrag |
droppable(options) |
dndDrop |
dragDroppable(options) |
dnd |
Controller methods
bind(element): Attach to one element. Callunbind()before moving to another.unbind(): Detach while keeping the controller available for reuse.update(optionsOrFunction): Replace options, with the same rules as Dynamic configuration.subscribe(callback): Receive the current state immediately, then each change. Returns a function to stop receiving notifications.getSnapshot(): Read the current state without subscribing. Treat it as read-only; repeated reads return the same object until the state changes.destroy(): Detach and stop all subscriptions. The controller cannot be reused afterward.
Controllers also accept onChange, with the same behavior as the actions. State fields are described in Observing changes. Store and controller types are exported alongside the action types, including DroppableController, DragDroppableController, and DragDroppableState.
Building an integration
An adapter can connect subscriptions to another framework's state and attach the controller when its element becomes available:
import { droppable } from "drag-droppable";
const controller = droppable({ type: "food" });
const stop = controller.subscribe((state) => {
// Copy the state into your framework's reactive state.
});
controller.bind(element);
// When options change:
controller.update({ type: "food", disabled: true });
// When the integration is no longer needed:
stop();
controller.destroy();
For an element that disappears temporarily, use unbind() and later bind(nextElement) instead of destroying the controller. React and Vue use these same controllers internally. Their returned controller allows advanced reads and subscriptions, but let the hook or composable manage its options, element, and cleanup.
Using a store in Svelte
Declare a controller in the component script and read its state through Svelte's $store syntax:
<script>
import { dndDrop, droppable } from "drag-droppable";
const drop = droppable({ type: "food" });
</script>
<div use:dndDrop={drop} class:highlight={$drop.accepts}>Drop food here</div>
When passing an existing controller to an action, update options through controller.update() and keep passing the same controller. The action detaches it when the element is removed, so it can be attached again later. Svelte handles $store subscriptions automatically. In JavaScript, the same pattern is available as dndDrop(element, controller) (or the corresponding source or combined action).
Related Packages
An agnostic drag and drop library to sort all kind of lists. With current suppor...
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 0.1.2 | ^3.3.0 | MIT | |
| 0.1.1 | ^3.3.0 | MIT | |
| 0.1.0 | ^3.3.0 | MIT |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |