Validating Drag and Drop File Uploads
Why do drop zones so often accept files the picker would refuse, submit nothing because the dropped files never reached the form, or leave keyboard users with no way to upload at all? Because the drop is treated as a separate feature instead of a second door into the same file input. This recipe keeps a real <input type="file"> as the single source of truth, moves dropped and pasted files into it with DataTransfer, fires the input’s change event so every existing check — type, size, dimensions — runs unchanged, and reports the verdict through setCustomValidity() on that input, which keeps the Constraint Validation API and reportValidity() in charge of submission.
When to Add a Validated Drop Zone
A drop zone is a genuine usability win on desktop for forms where users attach several files or work from a file manager beside the browser. Add one when:
- Users attach files often, such as support tickets, claims, or document submissions.
- Files come from other windows, including email attachments and screenshots.
- You already validate the picker, so the drop can reuse those checks rather than inventing new ones.
The drop zone never replaces the input. Touch devices have no drag and drop from the file system, keyboard and switch users cannot drag, and screen readers do not expose drop targets. The native input — or a styled label for it — must remain reachable and usable, which the file upload validation topic treats as the baseline.
Minimal Working Validated Drop Zone
<div class="dropzone" id="dropzone">
<input id="evidence" name="evidence" type="file" multiple class="file-input"
accept=".pdf,.png,.jpg,.jpeg" aria-describedby="evidence-hint evidence-err">
<label for="evidence" class="dropzone-label">
<span class="dropzone-title">Choose files</span>
<span class="dropzone-alt"> or drag them here</span>
</label>
<p id="evidence-hint" class="hint">PDF, PNG or JPEG, up to 10 MB each.</p>
<ul id="evidence-err" class="field-error-list" hidden></ul>
</div>
const zone = document.querySelector<HTMLElement>("#dropzone")!;
const input = document.querySelector<HTMLInputElement>("#evidence")!;
const MAX_FILES = 5;
/** Merge new files into the input, de-duplicating by name + size + lastModified. */
function addFiles(incoming: File[]): void {
const dt = new DataTransfer();
const seen = new Set<string>();
for (const f of [...(input.files ?? []), ...incoming]) {
const key = `${f.name}:${f.size}:${f.lastModified}`;
if (seen.has(key)) continue;
seen.add(key);
dt.items.add(f);
}
input.files = dt.files;
// Programmatic assignment fires nothing — dispatch so the normal validator runs.
input.dispatchEvent(new Event("change", { bubbles: true }));
}
// Dragging directories or non-file items (text, links) must be ignored, not "accepted".
function filesFrom(dt: DataTransfer | null): File[] {
if (!dt) return [];
return [...dt.items]
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((f): f is File => f !== null); // folders are handled separately (see edge cases)
}
let depth = 0; // dragenter/dragleave fire for every child element; count them
zone.addEventListener("dragenter", (e) => {
if (![...(e.dataTransfer?.types ?? [])].includes("Files")) return;
e.preventDefault();
depth++;
zone.dataset.dragging = "true";
});
zone.addEventListener("dragover", (e) => {
if (![...(e.dataTransfer?.types ?? [])].includes("Files")) return;
e.preventDefault(); // required, or the drop event never fires
e.dataTransfer!.dropEffect = "copy";
});
zone.addEventListener("dragleave", () => {
if (--depth <= 0) { depth = 0; delete zone.dataset.dragging; }
});
zone.addEventListener("drop", (e) => {
e.preventDefault(); // stop the browser navigating to the file
depth = 0;
delete zone.dataset.dragging;
addFiles(filesFrom(e.dataTransfer));
});
// Paste support: screenshots straight from the clipboard.
zone.addEventListener("paste", (e) => {
const files = filesFrom(e.clipboardData);
if (files.length) { e.preventDefault(); addFiles(files); }
});
// The single validator — the same one the picker uses.
input.addEventListener("change", async () => {
const files = [...(input.files ?? [])];
const errors = files.length > MAX_FILES ? [`You added ${files.length} files; the limit is ${MAX_FILES}.`] : [];
for (const f of files) {
const e = await validateOneFile(f); // type, size, dimensions from the sibling recipes
if (e) errors.push(e);
}
input.setCustomValidity(errors[0] ?? "");
input.toggleAttribute("aria-invalid", errors.length > 0);
renderErrors(errors);
});
Two lines do most of the work. input.files = dt.files makes dropped files part of the real form, so FormData and plain form submission include them with no extra code. dispatchEvent(new Event("change")) makes the drop indistinguishable from a pick as far as validation is concerned.
Drop Zone Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
dataTransfer.types check |
includes("Files") |
on | Ignores dragged text and links |
depth counter |
number |
0 |
Stops highlight flicker from child dragleave events |
dropEffect |
"copy" |
copy |
Shows the copy cursor over the zone |
| De-duplication key | name + size + lastModified | on | Dropping the same file twice adds it once |
MAX_FILES |
number |
5 |
Checked after merging picks and drops |
| Paste handling | paste event |
on | Accepts screenshots from the clipboard |
| Change dispatch | new Event("change") |
required | Runs the shared validator for drops and pastes |
Verification Steps
import { test, expect } from "@playwright/test";
test("dropped file goes through the picker's validation", async ({ page }) => {
await page.goto("/claim");
const dataTransfer = await page.evaluateHandle(() => {
const dt = new DataTransfer();
dt.items.add(new File(["not an image"], "photo.jpg", { type: "image/jpeg" }));
return dt;
});
await page.dispatchEvent("#dropzone", "drop", { dataTransfer });
await expect(page.locator("#evidence-err li")).toContainText("photo.jpg");
expect(await page.locator("#evidence").evaluate((el: HTMLInputElement) => el.files?.length)).toBe(1);
});
Edge Cases and Failure Modes
Dropping outside the zone navigates away. Browsers open a dropped file as a new page, discarding the form. Add a document-level guard that cancels dragover and drop everywhere except inside zones, and consider highlighting the zone when a file drag enters the window.
for (const type of ["dragover", "drop"] as const) {
window.addEventListener(type, (e) => {
if (!(e.target as Element).closest(".dropzone")) e.preventDefault();
});
}
Folders. Dropping a folder yields an entry with an empty type and size 0 in some browsers, or a zero-byte File. webkitGetAsEntry() can detect directories; if you do not support folders, reject them with “Folders can’t be uploaded — open the folder and choose the files” rather than a confusing type error.
Replacing instead of appending. The picker replaces the selection; drops in this recipe append. Be consistent: if users drop files and then open the picker, the recipe’s addFiles is not called by the picker, so the picker replaces everything. Either route picker results through addFiles too (keeping your own list), or explain in the hint that choosing files replaces the current selection.
Safari and DataTransfer constructor. Supported since Safari 14.1. For older versions, keep an array of files and submit with FormData built in script, validating the array directly.
Letting Users Remove a Rejected File
Once a drop has added a bad file, the user needs a way to remove just that file without clearing the others — the native input offers no per-file removal at all. Render the current selection as a list with a “Remove” button per file, and rebuild input.files without the removed entry using the same DataTransfer technique. Each button needs an accessible name that includes the file name (“Remove IMG_2041.heic”), because a column of identical “Remove” buttons is meaningless when read out of context. After removal, move focus to the next file’s button, or to the input’s label when the list becomes empty, so keyboard users are never left on a button that no longer exists.
function renderSelection(list: HTMLUListElement): void {
list.replaceChildren(...[...(input.files ?? [])].map((file, index) => {
const li = document.createElement("li");
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = "Remove";
btn.setAttribute("aria-label", `Remove ${file.name}`);
btn.addEventListener("click", () => {
const dt = new DataTransfer();
[...input.files!].forEach((f, i) => i !== index && dt.items.add(f));
input.files = dt.files;
input.dispatchEvent(new Event("change", { bubbles: true })); // re-validate
(list.querySelectorAll("button")[index] ?? document.querySelector("label[for=evidence]"))?.focus();
});
li.append(`${file.name} `, btn);
return li;
}));
}
Re-dispatching change after removal re-runs the validator, so removing the only bad file clears the custom error and the form becomes submittable without any extra state.
Accessible Feedback for Drops
A drop is silent for screen reader users, and even sighted users may not notice a file appearing in a list. After each drop or paste, announce the outcome in one sentence through a polite status region — “2 files added. 1 file has a problem.” — and let the error list carry the detail. Do not move focus on drop; the user’s pointer is on the zone and moving focus elsewhere is disorienting. On submit, the standard reportValidity() behaviour focuses the input and speaks the first file’s message, exactly as with the picker, which is the consistency this recipe is built around. For the broader announcement strategy, see asserting aria-live announcements in Playwright, which shows how to test that the status message fires once per drop.
Frequently Asked Questions
Why doesn't my drop event fire?
The browser only fires drop on elements whose dragover handler called preventDefault(). Cancel dragover for drags that carry files, and cancel drop itself so the browser does not open the file.
How do I get dropped files into a form submission?
Build a DataTransfer, add the files to it, and assign input.files = dt.files on a real file input inside the form. The files then submit with the form and appear in FormData like picked files.
Does the accept attribute apply to dropped files?
No. accept only filters the file picker. Dropped and pasted files must go through the same script checks as picked ones, which is why the recipe dispatches a change event after every drop.
Is a drop zone accessible on its own?
No. Keyboard, switch and touch users cannot drag files. Keep the native file input reachable, ideally with the drop zone's visible text as its label, so the zone is an enhancement rather than the only way in.
Related Guides
- File Upload Validation — the validator every drop is routed through.
- Validating File Type with Magic Bytes — the content check dropped files need most.
- Enforcing File Size Limits Before Upload — per-file and total limits after merging drops.
- Managing Focus After Validation Failure — where focus goes when a drop leaves the form invalid.