Why Dnd?
The HTML5 Drag & Drop API requires careful counter tracking to avoid hover state flicker, has no MIME type pre-filtering, and provides no sortable list abstraction.
ts
// Before — raw HTML5 Drag & Drop
let enterCount = 0;
dropzone.addEventListener('dragenter', () => {
enterCount++;
dropzone.classList.add('over');
});
dropzone.addEventListener('dragleave', () => {
if (--enterCount === 0) dropzone.classList.remove('over');
});
dropzone.addEventListener('dragover', (e) => e.preventDefault());
dropzone.addEventListener('drop', (e) => {
e.preventDefault();
enterCount = 0;
const files = [...e.dataTransfer!.files];
if (!files.every((f) => f.type.startsWith('image/'))) return showError('Images only');
uploadFiles(files);
});
// After — Dnd
import { createDropZone } from '@vielzeug/dnd';
const zone = createDropZone({
element: dropzone,
accept: ['image/*'],
onDrop: (files) => uploadFiles(files),
onDropRejected: (files) => showError(`${files.length} file(s) not accepted`),
onHoverChange: (hovered) => dropzone.classList.toggle('over', hovered),
});| Feature | DND | SortableJS | dnd-kit |
|---|---|---|---|
| Bundle size | 3.7 KB | ~15 kB | ~30 kB |
| Framework agnostic | |||
| MIME type filtering | |||
| Counter-based hover | N/A | ||
| Sortable lists | |||
| Drag handles | |||
using support | |||
| Zero dependencies |
Use Dnd when you need reliable file drop zones with MIME filtering or sortable lists in a framework-agnostic environment.
Consider dnd-kit if you are building a React app and need complex multi-container drag interactions or accessibility-first sortable trees.
Installation
sh
pnpm add @vielzeug/dndsh
npm install @vielzeug/dndsh
yarn add @vielzeug/dndQuick Start
ts
import { createDropZone, createSortable } from '@vielzeug/dnd';
// File drop zone — with async validation and paste support
using zone = createDropZone({
element: document.getElementById('dropzone')!,
accept: ['image/*', '.pdf'],
paste: true,
onValidate: async (files) => checkServerQuota(files),
onDrop: (files) => uploadFiles(files),
onDropRejected: (files) => {
showError(`${files.length} file(s) not accepted`);
},
onHoverChange: (hovered) => {
document.getElementById('dropzone')!.classList.toggle('drag-over', hovered);
},
});
// Sortable list — with revert support for optimistic updates
using sortable = createSortable({
element: document.getElementById('list')!,
keyboard: true,
onBeforeReorder: (from, to) => {
// record positions here before the DOM commits (for FLIP animations)
},
getKey: (el) => el.dataset.sortId!,
onReorder: ({ ids, setRevert }) => {
const prev = currentOrder;
setOrder(ids);
setRevert(() => setOrder(prev)); // enable sortable.revert() on failure
},
});Features
- Counter-based hover state —
onHoverChangestays accurate when dragging over child elements; hover only activates when the drag payload passes theacceptfilter, with symmetric enter/leave pairing to prevent flicker - MIME type pre-validation — queries
dataTransfer.itemsduring drag to setdropEffect='none'before the drop; confirmed againstFile.typeon drop - Flexible accept patterns — MIME types (
image/png), wildcards (image/*), and file extensions (.pdf) maxFileslimit — cap the number of accepted files per drop; excess files are forwarded toonDropRejectedonValidateasync gating — optional async step after type filtering;zone.validatingistruewhile a promise is pending; only receives type-accepted files- Clipboard paste support —
paste: trueroutes pasted files through the sameaccept,maxFiles, andonValidatepipeline;onPasteprovides a separate callback; paste rejections are forwarded toonDropRejectedwith the same(files: File[]) => voidsignature as drop rejections onDropRejected— separate callback for files that didn't matchaccept, exceededmaxFiles, or were rejected byonValidate; event type reflects whether the rejection came from a drop or a paste- Sortable lists — reorders DOM children with a placeholder indicator; fires
onReorderonly when the order actually changes - Drag handles — scope dragging to a child selector via
handle; whole item is draggable when omitted - Custom drag preview — pass an element or a
(id, item, event) => element | nullfactory; control hotspot withdragImageOffset onBeforeReorderFLIP hook — fires before commit for both drag and keyboard moves; items are still in pre-commit positions, making it ideal for FLIP animation setupsortable.revert()— register a revert function viaevent.setRevert(fn)insideonReorder;sortable.revert()invokes it and clears it for rolling back optimistic updates on server failure- Boundary-safe keyboard reordering — arrow keys at the first/last item no longer suppress
preventDefault, so the browser can scroll the page normally - Explicit connected scopes — lists only exchange items when they share a
createSortableScope()instance - Explicit DOM sync — call
sortable.sync()after DOM mutations instead of relying on hidden observers [Symbol.dispose]— both primitives support theusingkeyword for automatic cleanup- Reactive-friendly options —
disabledis re-read on each event (reassignoptions.disabled = trueto toggle);acceptcaptures the array reference, so push/splice mutations are reflected without recreating the zone - Zero dependencies — 3.7 KB gzipped, 0 dependencies
Documentation
See Also
- Orbit — floating element positioning; use alongside Dnd to anchor drag previews and drop-zone indicators to precise positions
- Craft — web-component authoring framework; build draggable custom elements with Dnd's pointer event primitives
- Sigil — accessible web components; Dnd powers the drag-and-drop inside Sigil's sortable list and kanban components