Drag-and-Drop File Uploads
A drag-and-drop drop zone feels effortless to users but hides a sharp edge: the browserโs default behavior is to navigate away and open the dropped file, so a single missing preventDefault() silently destroys the feature. This guide builds a production drop zone in TypeScript that handles the full event sequence, reads files from the DataTransfer object, accepts pasted images, and degrades to a keyboard-accessible <input type="file"> fallback.
Drag-and-drop sits inside the upload fundamentals and browser APIs layer: it is purely a way to acquire File objects. Once you hold those files you read them with the File API and Blob objects and transmit them with the modern Fetch API for uploads. Everything below is about getting clean File[] into your hands.
Prerequisites
- [ ] Node 20+ and a bundler that transpiles TypeScript (Vite, esbuild, or tsc)
- [ ] A browser target of Chromium 90+, Firefox 90+, or Safari 15+
- [ ] Familiarity with the
FileandBlobinterfaces - [ ] A destination endpoint or presigned URL to send files to
- [ ]
lib: ["DOM", "DOM.Iterable", "ES2022"]in yourtsconfig.json
How drag-and-drop works under the hood
The HTML Drag and Drop API fires a sequence of events on the element under the pointer. The drop target receives dragenter when a dragged item first crosses its boundary, then a stream of dragover events roughly every few hundred milliseconds while the pointer hovers, a dragleave when the pointer exits, and finally drop when the user releases.
The counter-intuitive part is that the browser defaults to rejecting drops. To mark an element as a valid drop target you must call event.preventDefault() on both dragover and drop. The dragover handler is the one that actually tells the browser โyes, a drop is allowed hereโ; skip it and the drop event never fires at all โ the browser instead opens the file in the current tab.
During a drag you donโt have access to the file contents (a security measure), only metadata via DataTransfer.items. The File objects themselves become readable in the drop handler through DataTransfer.files or, for richer access including directories, DataTransferItem.webkitGetAsEntry().
Building an accessible drop zone in TypeScript
Step 1: Mark up the drop zone with a real input inside
Never build a drop zone out of a bare <div>. Wrap a hidden <input type="file"> so keyboard and screen-reader users can still pick files, and so clicking the zone opens the native picker. The visible region is the affordance; the input is the engine.
<label id="dropzone" class="dropzone" tabindex="0">
<span class="dropzone__hint">Drag files here, paste, or
<span class="dropzone__link">browse</span>
</span>
<input id="fileInput" type="file" multiple accept="image/*,application/pdf" hidden />
<ul id="fileList" class="dropzone__list" aria-live="polite"></ul>
</label>
Because the <input> lives inside a <label>, clicking anywhere on the zone opens the picker for free โ no JavaScript click forwarding required. The aria-live="polite" list announces added files to assistive technology.
Step 2: Wire the four drag events with the required preventDefault
const zone = document.getElementById("dropzone") as HTMLLabelElement;
const input = document.getElementById("fileInput") as HTMLInputElement;
// Stop the browser from opening dropped files in the tab.
function suppress(event: Event): void {
event.preventDefault();
event.stopPropagation();
}
// dragover MUST call preventDefault or the drop event never fires.
zone.addEventListener("dragover", (event: DragEvent) => {
suppress(event);
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
zone.classList.add("dropzone--active");
});
zone.addEventListener("dragenter", (event: DragEvent) => {
suppress(event);
zone.classList.add("dropzone--active");
});
zone.addEventListener("dragleave", (event: DragEvent) => {
suppress(event);
// Only deactivate when the pointer truly left the zone, not a child.
if (!zone.contains(event.relatedTarget as Node)) {
zone.classList.remove("dropzone--active");
}
});
Setting dataTransfer.dropEffect = "copy" changes the cursor to a plus sign, signalling that releasing will add a copy rather than move the source.
Step 3: Handle the drop and normalize to File[]
async function handleFiles(files: File[]): Promise<void> {
const list = document.getElementById("fileList") as HTMLUListElement;
for (const file of files) {
const item = document.createElement("li");
item.textContent = `${file.name} โ ${(file.size / 1024).toFixed(1)} KB`;
list.appendChild(item);
}
// Hand off to your reader/uploader here.
console.log(`[dropzone] accepted ${files.length} file(s)`);
}
zone.addEventListener("drop", (event: DragEvent) => {
suppress(event);
zone.classList.remove("dropzone--active");
const dt = event.dataTransfer;
if (!dt) return;
const files = Array.from(dt.files);
void handleFiles(files);
});
// Keyboard / click fallback: the native picker fires change.
input.addEventListener("change", () => {
if (input.files) void handleFiles(Array.from(input.files));
input.value = ""; // allow re-selecting the same file
});
Expected console output after dropping two images:
[dropzone] accepted 2 file(s)
Step 4: Add paste-to-upload
Users routinely copy a screenshot and expect to paste it. The clipboard exposes files through the same shape as a drop โ ClipboardEvent.clipboardData.files.
document.addEventListener("paste", (event: ClipboardEvent) => {
const data = event.clipboardData;
if (!data || data.files.length === 0) return;
event.preventDefault();
void handleFiles(Array.from(data.files));
});
Step 5: Restore keyboard activation
A <label> is not focusable by default for Enter/Space activation of its input across all browsers, so forward those keys explicitly.
zone.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
input.click();
}
});
Configuration reference
| Option | Type | Default | Effect |
|---|---|---|---|
input.multiple |
boolean | false |
Allows selecting/dropping more than one file. |
input.accept |
string | "" |
Filters the native picker (a hint, not validation) by MIME or extension. |
dataTransfer.dropEffect |
"copy" | "move" | "link" | "none" |
"copy" on most targets |
Cursor feedback and the operation the browser reports. |
dataTransfer.effectAllowed |
string | "uninitialized" |
Set on dragstart for in-page sources to constrain valid effects. |
dataTransfer.types |
readonly string[] | [] |
Lists the kinds of data being dragged ("Files" for OS files). |
aria-live on list |
"polite" | "assertive" |
none | Announces newly added files to screen readers. |
Edge cases and gotchas
dragover preventDefault is mandatory
This is the single most common bug. If you only preventDefault() on drop but not dragover, the drop event is never dispatched and the browser navigates to the file. Always handle dragover first.
dragleave fires when hovering child elements
dragleave bubbles up every time the pointer crosses into a child of the zone (text, the file list, icons), causing the active highlight to flicker. Guard with zone.contains(event.relatedTarget as Node) as shown in Step 2, or maintain an enter/leave counter.
Directories vs files
A user can drag a folder. DataTransfer.files will contain a zero-byte entry for the directory rather than its contents. To walk a dropped folder recursively you need webkitGetAsEntry() โ covered in handling dropped folders with the DataTransfer API.
Large drops block the main thread
Dropping hundreds of files at once and synchronously reading each one freezes the UI. Process them in batches and offload reading to the techniques in reading files with FileReader and ArrayBuffer.
accept does not validate
The accept attribute only filters the picker UI; users can still drop disallowed types. Re-check file.type and magic bytes server-side before trusting anything.
Verification
Confirm the events and file count in DevTools without touching your server:
// Paste into the DevTools console with the page open.
const z = document.getElementById("dropzone")!;
for (const name of ["dragenter", "dragover", "dragleave", "drop"]) {
z.addEventListener(name, (e) => {
const dt = (e as DragEvent).dataTransfer;
console.log(name, "defaultPrevented:", e.defaultPrevented, "files:", dt?.files.length ?? 0);
});
}
Drop a file and check that dragover shows defaultPrevented: true and drop reports a non-zero files count. If drop never logs, your dragover handler is missing preventDefault().
FAQ
Why does my dropped file open in the browser instead of triggering my handler?
Your dragover handler is missing event.preventDefault(). The browser only treats an element as a drop target when dragover cancels the default action; otherwise it falls back to opening the file in the tab and your drop listener never runs.
How do I let users paste a screenshot to upload it?
Listen for the paste event on document and read event.clipboardData.files. It has the same shape as a dropโs DataTransfer.files, so you can route both through one handleFiles(files: File[]) function.
Can keyboard-only users use a drag-and-drop zone?
Not the drag gesture itself, which is why an accessible implementation wraps a real <input type="file">. Make the zone a <label>, forward Enter/Space to input.click(), and announce additions through an aria-live region.
Why does my highlight flicker as I drag across the zone?
dragleave fires whenever the pointer enters a child element. Only remove the active class when event.relatedTarget is outside the zone (!zone.contains(relatedTarget)), or track enter/leave with a counter.