You meet the file dropzone everywhere you upload something: a Gmail attachment, a profile photo, a CSV import, a Figma asset. It looks like a trivial widget — a dashed box and a list — and the layout genuinely is trivial. Everything worth learning is the behaviour, and it hinges on one line most people forget. A browser's default reaction to a file dropped anywhere on the page is to navigate to it: your whole app vanishes and you're staring at a raw JPEG. I built a real dropzone from scratch — no library — to nail down every piece: stopping that default, highlighting on drag, reading the dropped files, a click-to-browse fallback, validation with a reason, per-file progress, and image thumbnails. Here's what it takes.
preventDefault, or the browser eats your app
This is the line without which nothing else runs. To claim the drop you have to call e.preventDefault() on both dragover and drop — dragover especially, because if you don't cancel it the browser never even offers you the drop event. I do it on the zone and, as a safety net, on the whole window, so a near-miss that lands a few pixels outside the box doesn't blow the page away either:
["dragenter","dragover","dragleave","drop"].forEach(evt =>
zone.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); })
);
["dragover","drop"].forEach(evt =>
window.addEventListener(evt, e => e.preventDefault()) // safety net
);
Highlight on drag — and the depth-counter trap
A dropzone should glow while a file hovers so you're sure the drop will land. You add a class on dragenter/dragover and remove it on dragleave/drop — and immediately it flickers. The trap: dragleave also fires the moment the pointer crosses from the box onto a child element inside it. Two fixes stack cleanly. Set pointer-events:none on the box's children so they never generate their own drag events, and keep a small depth counter — increment on enter, decrement on leave, and only un-highlight when it hits zero. Then the glow is rock-steady.
Read the drop, and converge two paths into one
The payload hangs off the event at e.dataTransfer.files — a FileList of real File objects, each already carrying .name, .size (bytes) and .type (a MIME string). A FileList is array-like, not an array, so I spread it with [...files] before mapping. The key move is that both entry points hand off to one shared handleFiles():
zone.addEventListener("drop", e => {
depth = 0; zone.classList.remove("dz-over");
handleFiles(e.dataTransfer.files);
});
picker.addEventListener("change", () => {
handleFiles(picker.files); // SAME path as drop
picker.value = ""; // so re-picking the same file still fires change
});
Dragging is hopeless on a phone and awkward on a trackpad, so every real uploader doubles as a plain picker. You can't style a native file input, so I hide the real one and forward clicks to picker.click(). Resetting picker.value afterwards is the subtle bit — without it, picking the same file twice never re-fires change.
Validate with a reason, and let state be the truth
Never trust what lands. I check the File.type against an allow-list, fall back to the filename extension because some files arrive with an empty type, and check the byte size against a max. The UX rule that separates a good dropzone from a bad one: a rejected file must not silently vanish — return a human reason and show it on the row.
function validate(file) {
const ext = (file.name.split(".").pop() || "").toLowerCase();
const typeOk = OK_MIME.test(file.type) || OK_EXT.includes(ext);
if (!typeOk) return "Unsupported type — images or PDF only";
if (file.size > MAX) return "Too large — 5 MB max";
if (file.size === 0) return "Empty file";
return null; // null === valid
}
Everything the widget knows lives in one items array of plain objects — each with the raw File, a status, a progress number, a thumbnail slot and a stable id — and the list is rendered from it, never poked directly. Add, progress, remove: every feature is just an edit to that array followed by a re-render, so the DOM and the data can never disagree.
Progress and thumbnails, both from bytes already in the browser
A real upload would read XMLHttpRequest.upload.onprogress; here I simulate it with an interval that nudges progress to 100 and flips status to "done". Timers are keyed by file id so removing a file mid-upload cancels exactly its timer — no orphaned interval writing to a row that's gone. And you can preview an image before it's uploaded anywhere, because its bytes are already local. A FileReader with readAsDataURL hands back a data: URL you drop straight into an <img src>; it's async, so store it on the item and re-render when onload fires. Removal is one delegated click listener on the container reading the id off the button — not a handler per button, since the list re-renders constantly. Get those pieces right and you have a real uploader with zero dependencies.
Try it — drag a few files in, or click to browse, and watch the read-out update live:
https://dev48v.infy.uk/design/day45-file-dropzone.html
Top comments (0)