Drag-and-drop in the browser rests on a single object: dataTransfer. Every drag event — dragstart, dragenter, dragover, drop — carries a reference to this object, and nearly every drag-and-drop bug comes down to either using it incorrectly or not understanding when its data is even available. This guide walks through the full surface of dataTransfer, the lifecycle of a drag operation, and the quirks you will hit the first time you ship a real-world drop zone.
What dataTransfer is
dataTransfer is the drag-and-drop sibling of clipboardData. It is a temporary container that describes what is being dragged and what the target should do with it. Conceptually, it holds three things:
- Typed string data — a dictionary of MIME type → string, accessed through
setDataandgetData. - File references — a
FileListof actual file handles, accessed through.files. - Effect metadata —
effectAllowedanddropEffectindicate what kind of operation is being performed (copy, move, link).
It also has an .items collection (DataTransferItemList), which is the modern unified view over both strings and files. For new code, prefer .items.
The lifecycle of a drag
A drag-and-drop operation from the user's perspective is a single gesture. From the event perspective, it is a sequence:
- dragstart — fires on the source element when the user begins dragging. This is where you (if you own the source) populate
dataTransferwith what should be transferred. - drag — fires repeatedly on the source as the user moves the mouse. Rarely useful.
- dragenter — fires on a potential target when the cursor first enters. Call
event.preventDefault()to signal "this element accepts drops". - dragover — fires continuously while the cursor is over a target. Must also call
event.preventDefault()to keep the drop active. UpdatedropEffecthere to change the cursor feedback. - dragleave — fires when the cursor leaves a target.
- drop — fires on the target if the user releases the mouse while
preventDefault()is in effect. This is wheredataTransferis fully readable. - dragend — fires on the original source after the drop (or cancellation).
The most common bug for new drag-and-drop developers is forgetting to preventDefault() in dragover. The default behaviour of the browser is to reject drops; calling preventDefault says "I will handle this". Without it, the cursor shows a "no-drop" icon and the drop event never fires. There is nothing wrong with your drop handler — it simply has not been asked to run.
Reading files on drop
The case every uploader cares about is "user drops a file from the OS file manager into my drop zone". Implementation:
zone.addEventListener('dragover', (e) => e.preventDefault());
zone.addEventListener('drop', (e) => {
e.preventDefault();
for (const file of e.dataTransfer.files) {
uploadFile(file);
}
});
dataTransfer.files is a FileList — array-like but not an array — with length and indexed access. Spread it into an array with [...e.dataTransfer.files] if you want to use array methods.
Folders are trickier
If the user drags a folder (not a file), .files will include an entry with type === '' and a size that is meaningless. Reading it as a file fails. To recurse into a folder, use the newer webkitGetAsEntry() method on items:
for (const item of e.dataTransfer.items) {
const entry = item.webkitGetAsEntry();
if (entry.isDirectory) await readFolder(entry);
else if (entry.isFile) uploadFile(await fileFromEntry(entry));
}
Yes, the API is prefixed webkit and yes it is also implemented in Chrome, Firefox, and Safari. Treat it as the de facto standard. The newer FileSystemHandle API covers directories more cleanly but is available only in Chromium browsers and requires a showDirectoryPicker user gesture — inapplicable to drag-and-drop.
Dragging text and HTML
Dragging a selection of text from another web page or another application produces text/plain and often text/html entries. Read them the same way you read clipboard data:
const text = e.dataTransfer.getData('text/plain');
const html = e.dataTransfer.getData('text/html');
A rich-text drop zone should prefer text/html when available and fall back to text/plain wrapped in a paragraph — the same approach as paste (see the formatting guide).
Dragging URLs
When the user drags a bookmark, tab, or link, the drop contains text/uri-list and text/plain, both holding the URL. As always with URI lists, parse defensively — split on newlines, skip comment lines starting with #, trim whitespace. If your feature imports bookmarks or embeds URLs, this is the authoritative source.
The difference between items and files
dataTransfer.items contains both string entries and file entries; each item has a kind of 'string' or 'file'. For string items, call item.getAsString(callback); for file items, call item.getAsFile(). Modern code typically iterates through items, branches on kind, and handles each type explicitly.
dataTransfer.files is a convenience shortcut to only the file entries, as a FileList. It does not include string items — a drop of pure HTML text produces an empty .files even though data is present.
effectAllowed and dropEffect
These two properties control the cursor icon and the conceptual operation:
effectAllowedis set indragstartby the source. Valid values include'copy','move','link','copyMove','all', etc. It caps what the target may do.dropEffectis set indragoverby the target. It indicates what the target intends if the drop happens. The browser uses it to choose the cursor icon (arrow with plus for copy, arrow for move, arrow with link badge for link).
When these do not match, the cursor icon looks wrong to users — "I'm dragging a file in, why is the cursor showing the prohibition icon?". Set dropEffect explicitly in every dragover handler, even if it is just 'copy'.
Dragging images from within the same page
An <img> is draggable by default. When the user drags one into a drop zone on the same page, dataTransfer usually contains text/uri-list (the image's src) and text/html (the image tag's HTML). It does not contain the image bytes. If your feature uploads dropped images, fetch the URL inside your drop handler:
const url = e.dataTransfer.getData('text/uri-list');
const res = await fetch(url);
const blob = await res.blob();
uploadBlob(blob);
This works for same-origin images and for cross-origin images that send permissive CORS headers. If the fetch fails because of CORS, the user has to save the image locally and drop it from the filesystem.
Cross-tab and cross-window drops
Dropping from one browser tab into another works for typed data (text/plain, text/uri-list, text/html) but not for file objects. The browser cannot hand a file handle across processes. If your target needs bytes, use a typed URL that the receiving tab can fetch.
Keyboard accessibility
Drag-and-drop is not inherently keyboard-accessible. WCAG requires an equivalent keyboard path for every drag gesture. For drop zones, this usually means:
- A visible "or choose file" button that triggers a native file picker.
- Tab-focus on the drop zone with aria labels describing what it accepts.
- An
Enter/Spacekey handler that fires the same picker.
The drag path is fine for mouse and trackpad users — but never be the only option.
Touch support
Drag-and-drop events do not fire for touch gestures on most browsers. Mobile users need a dedicated upload UI (a tap-to-pick button). Some libraries synthesise drag events from touch events; that is fine for reordering list items on mobile but unreliable for file drops because mobile operating systems do not expose file-manager drags to web pages.
Inspecting dataTransfer live
A handy debugging trick: log the full contents of dataTransfer on every drop during development, including types, files, and sample values per type. Or open Clipboard Inspector, which has a drop zone that renders every representation the browser exposes. Drag anything into it — a file, a browser tab, a selection from another web page, a screenshot — and see exactly what your own drop handler would see.
Common pitfalls recap
- Missing
preventDefaultindragover. Drop never fires. - Reading files synchronously and then awaiting. Copy bytes into a blob inside the handler.
- Assuming
.filescovers folders. It does not; usewebkitGetAsEntry. - Not setting
dropEffect. The cursor lies to the user. - Forgetting the keyboard path. Accessibility fails.
Drag-and-drop is genuinely hard because it sits at the intersection of the OS, the browser, multiple events, and a temporary object that behaves differently depending on which phase of the gesture you read it in. But it is observable at every step. Log, inspect, and iterate, and a flaky drop zone becomes a reliable part of your UI.