Basic Usage
createDropZone attaches drag-and-drop behavior to any DOM element and keeps hover state stable with a counter.
import { createDropZone } from '@vielzeug/dnd';
const zone = createDropZone({
element: document.getElementById('dropzone')!,
onDrop: (files) => {
uploadFiles(files);
},
});Accept filtering
const zone = createDropZone({
element: dropEl,
accept: ['image/*', '.pdf', 'application/json'],
onDrop: (files) => {
// accepted files only
},
onDropRejected: (files) => {
showToast(`${files.length} file(s) not accepted`);
},
});The accept list is read at drop-time, so mutating the array dynamically adjusts what is accepted for the next drop.
Hover state
const zone = createDropZone({
element: dropEl,
onHoverChange: (hovered) => {
dropEl.classList.toggle('drag-over', hovered);
},
});Read zone state imperatively:
console.log(zone.hovered);
console.log(zone.validating);Drop effect
createDropZone({
element: dropEl,
dropEffect: 'move',
onDrop: (files) => {
// ...
},
});Disabled state
const options = { disabled: false, element: dropEl, onDrop: handleFiles };
const zone = createDropZone(options);
// options.disabled is read live on each event — mutate to toggle:
options.disabled = isReadOnly;File limit
const zone = createDropZone({
element: dropEl,
accept: ['image/*'],
maxFiles: 5,
onDrop: (files) => {
// 1-5 accepted files
},
onDropRejected: (files) => {
showToast(`Only 5 files at a time. ${files.length} were ignored.`);
},
});Cleanup
zone.dispose();
// or:
using zone = createDropZone({ element: dropEl, onDrop: handleFiles });Async validation
Gate drops behind an async check with onValidate. The zone sets validating: true while the promise is pending; on resolution, accepted files go to onDrop and rejected files go to onDropRejected.
const zone = createDropZone({
element: dropEl,
accept: ['image/*'],
onValidate: async (files) => {
const ok = await checkServerQuota(files);
return ok; // false → all files forwarded to onDropRejected
},
onDrop: (files) => uploadFiles(files),
onDropRejected: (files) => showError('Quota exceeded'),
});
// show a spinner while checking
console.log(zone.validating); // true during pending checkA synchronous boolean return skips the microtask queue entirely:
const zone = createDropZone({
element: dropEl,
onValidate: (files) => files.every((f) => f.size < 5_000_000), // sync
onDrop: handleFiles,
});Clipboard paste
Set paste: true to accept files pasted from the clipboard. The same accept, maxFiles, and onValidate pipeline applies.
const zone = createDropZone({
element: dropEl,
paste: true,
accept: ['image/*'],
onPaste: (files) => {
uploadFiles(files);
},
onDropRejected: (files) => {
showError(`${files.length} file(s) not accepted`);
},
});When onPaste is omitted, accepted pasted files fall through to onDrop.
Sortable
createSortable makes direct children of a container reorderable via drag.
Setup
<ul id="task-list">
<li data-sort-id="task-1">Design</li>
<li data-sort-id="task-2">Develop</li>
<li data-sort-id="task-3">Review</li>
</ul>const sortable = createSortable({
element: document.getElementById('task-list')!,
getKey: (el) => el.dataset.sortId!,
axis: 'vertical',
onReorder: ({ ids }) => {
saveTaskOrder(ids);
},
});Dnd automatically sets:
draggable="true"on sortable nodes (or handles)role="listitem"on each itemrole="list"on the containertabindex="0"on each item for keyboard reordering
Drag handles
createSortable({
element: listEl,
getKey: (el) => el.dataset.sortId!,
handle: '.drag-handle',
onReorder: ({ ids }) => saveOrder(ids),
});Keyboard reordering
Focus an item and use arrow keys to move it. Home and End move to the boundary positions.
When an item is already at the first or last position, the boundary key press is not consumed — the browser handles it normally (for example, scrolling the page). Only keys that actually move an item call preventDefault.
Connected lists
Create a shared scope when items should move between containers:
const boardScope = createSortableScope();
createSortable({
element: todoEl,
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids }) => saveTodoOrder(ids),
scope: boardScope,
});
createSortable({
element: doneEl,
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids }) => saveDoneOrder(ids),
scope: boardScope,
});Auto-scroll and drag preview
createSortable({
element: listEl,
getKey: (el) => el.dataset.sortId!,
autoScroll: { edgeThreshold: 40, speed: 24, viewport: true },
dragImage: (id, item) => item,
dragImageOffset: [8, 8],
});Viewport scrolling is opt-in. Container scrolling stays enabled by default.
Lifecycle hooks
createSortable({
element: listEl,
getKey: (el) => el.dataset.sortId!,
onDragStart: (id) => {
listEl.classList.add('sorting');
},
onDragEnd: (id) => {
listEl.classList.remove('sorting');
},
onReorder: ({ ids }) => saveOrder(ids),
});Custom identity function
createSortable({
element: listEl,
getKey: (el) => el.getAttribute('data-id')!,
onReorder: ({ ids }) => saveOrder(ids),
});Dynamic lists
Call sortable.sync() after adding, removing, or replacing sortable items.
const item = document.createElement('li');
item.dataset.sortId = 'task-4';
item.textContent = 'Deploy';
listEl.appendChild(item);
sortable.sync();Disabled state
import { createSortable, type SortableOptions } from '@vielzeug/dnd';
const options: SortableOptions = {
disabled: false,
element: listEl,
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids }) => saveOrder(ids),
};
const sortable = createSortable(options);
// options.disabled is read live on each event — mutate to toggle:
options.disabled = isLocked;Placeholder styling
.dnd-placeholder {
background: var(--color-primary-50);
border: 2px dashed var(--color-primary-300);
border-radius: 4px;
box-sizing: border-box;
}
[data-dragging] {
opacity: 0.35;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}Mapping DOM order back to data
import { applyReorder, createSortable } from '@vielzeug/dnd';
let items = [
{ id: 'task-1', title: 'Design' },
{ id: 'task-2', title: 'Develop' },
{ id: 'task-3', title: 'Review' },
];
createSortable({
element: listEl,
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids }) => {
items = applyReorder(items, ids, (item) => item.id);
},
});Cleanup
sortable.dispose();
// or:
using sortable = createSortable({
element: listEl,
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids }) => saveOrder(ids),
});FLIP animation hook
onBeforeReorder fires just before the DOM reorder commits, for both drag and keyboard moves. At the time of the call items are still in their pre-commit positions, making it the right place to record element bounds for FLIP animations.
const sortable = createSortable({
element: listEl,
onBeforeReorder: (from, to) => {
// snapshot bounds before the DOM moves
const snapshots = new Map(getItems().map((el) => [el.dataset.sortId!, el.getBoundingClientRect()]));
requestAnimationFrame(() => {
// animate from snapshot to new position
for (const [id, before] of snapshots) {
const el = listEl.querySelector(`[data-sort-id="${id}"]`) as HTMLElement;
const after = el.getBoundingClientRect();
const dy = before.top - after.top;
if (dy === 0) continue;
el.style.transform = `translateY(${dy}px)`;
el.style.transition = 'none';
requestAnimationFrame(() => {
el.style.transition = 'transform 200ms ease';
el.style.transform = '';
});
}
});
},
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids }) => saveOrder(ids),
});Optimistic updates and revert
Call sortable.revert() to roll back the most recent reorder. Register a revert function via setRevert inside onReorder.
const sortable = createSortable({
element: listEl,
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids, setRevert }) => {
const prev = currentOrder;
setOrder(ids); // optimistic update
setRevert(() => setOrder(prev)); // registered for sortable.revert()
},
});
// On server error:
try {
await api.saveOrder(currentOrder);
} catch {
sortable.revert();
}Framework Integration
import { useEffect, useRef } from 'react';
import { createSortable, applyReorder } from '@vielzeug/dnd';
function SortableList({ initialItems }: { initialItems: { id: string; text: string }[] }) {
const listRef = useRef<HTMLUListElement>(null);
const items = useRef(initialItems);
useEffect(() => {
const sortable = createSortable({
element: listRef.current!,
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids }) => {
items.current = applyReorder(items.current, ids, (i) => i.id);
},
});
return () => sortable.dispose();
}, []);
return (
<ul ref={listRef}>
{initialItems.map((item) => (
<li key={item.id} data-sort-id={item.id}>
{item.text}
</li>
))}
</ul>
);
}import { ref, onMounted, onUnmounted } from 'vue';
import { createSortable, applyReorder, type Sortable } from '@vielzeug/dnd';
function useSortable(items: { id: string; text: string }[]) {
const listRef = ref<HTMLElement | null>(null);
const orderedItems = ref(items);
let sortable: Sortable | null = null;
onMounted(() => {
sortable = createSortable({
element: listRef.value!,
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids }) => {
orderedItems.value = applyReorder(orderedItems.value, ids, (i) => i.id);
},
});
});
onUnmounted(() => sortable?.dispose());
return { listRef, orderedItems };
}<script lang="ts">
import { onMount } from 'svelte';
import { createSortable, applyReorder } from '@vielzeug/dnd';
export let initialItems: { id: string; text: string }[] = [];
let items = initialItems;
let listEl: HTMLUListElement;
onMount(() => {
const sortable = createSortable({
element: listEl,
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids }) => { items = applyReorder(items, ids, (i) => i.id); },
});
return () => sortable.dispose();
});
</script>
<ul bind:this={listEl}>
{#each items as item (item.id)}
<li data-sort-id={item.id}>{item.text}</li>
{/each}
</ul>Working with Other Vielzeug Libraries
With Craft
Use Dnd in custom web components by attaching behavior in component lifecycle hooks.
import { createSortable } from '@vielzeug/dnd';
import { define, onMounted, html } from '@vielzeug/craft';
define('task-list', {
setup(_props, { host }) {
onMounted(() => {
const sortable = createSortable({
element: host.el,
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids }) => save(ids),
});
return () => sortable.dispose();
});
return () => html`<slot></slot>`;
},
});Best Practices
- Attach
createDropZoneandcreateSortableafter the container element is in the DOM — useonMountedin component frameworks. - Call
.dispose()in the cleanup phase of your framework (useEffect return, onUnmounted, onDestroy) to prevent memory leaks. - Use
data-sort-idattributes that match your data's identity field — do not use DOM index as an identifier. - Prefer
applyReorder()over manual array splicing to keep your data array in sync with DOM order. - Use
createSortableScope()only when items should genuinely move between containers. - Use drag handles (
.handleselector) when the full item surface area conflicts with other interactions such as text selection. - Test keyboard reordering explicitly — Dnd sets
tabindexon items and supports arrow keys by default.