A File Drop Zone Without a Library
The drop zone I keep instead of pulling in react-dropzone (60+kB minified): drag-over visuals, multi-file drops, folder uploads via DataTransferItem, and the Safari quirk where dragleave fires on every child enter.
By @isabellarashid
December 6, 2025
·
Updated May 18, 2026
323 views
1
4.4 (13)
// The depth counter is the trick that makes drag-over visuals not flicker.
// Every drag-enter into a child fires `dragenter` on the parent (bubbling),
// and every drag-leave from a child fires `dragleave`. If you toggle a class
// based on those events naively, hovering over a button inside the zone
// makes the highlight flash. Counting depth fixes that.
function makeDropZone(el, onFiles) {
let depth = 0;
const setActive = (active) => {
// In a real DOM we would toggle a class; in the playground we log.
console.log('drop zone active:', active);
};
el.addEventListener('dragenter', (e) => {
e.preventDefault();
depth += 1;
if (depth === 1) setActive(true);
});
el.addEventListener('dragover', (e) => {
// dragover MUST be prevented to allow a drop. Forgetting this is the
// single most common reason a drop zone silently does nothing.
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
el.addEventListener('dragleave', (e) => {
e.preventDefault();
depth -= 1;
if (depth <= 0) {
depth = 0;
setActive(false);
}
});
el.addEventListener('drop', (e) => {
e.preventDefault();
depth = 0;
setActive(false);
const files = Array.from(e.dataTransfer.files);
onFiles(files);
});
return () => {
// Caller can detach by overwriting; for brevity we omit removeEventListener.
};
}
// Drive the lifecycle synthetically since the playground has no real DOM.
const listeners = {};
const fakeEl = {
addEventListener(name, fn) { listeners[name] = fn; },
};
const fakeFile = { name: 'photo.png', size: 12345, type: 'image/png' };
const makeEvent = (files = []) => ({
preventDefault() {},
dataTransfer: { files, dropEffect: '' },
});
makeDropZone(fakeEl, (files) => console.log('got', files.length, 'file(s):', files.map((f) => f.name)));
listeners.dragenter(makeEvent()); // depth 0 -> 1, active
listeners.dragenter(makeEvent()); // depth 1 -> 2, no-op
listeners.dragleave(makeEvent()); // depth 2 -> 1, no-op
listeners.dragleave(makeEvent()); // depth 1 -> 0, inactive
listeners.dragenter(makeEvent()); // re-enter, active
listeners.dragover(makeEvent());
listeners.drop(makeEvent([fakeFile])); // emits filesThe depth counter is the part nobody warns you about. The MDN docs show a single boolean isOver, which works fine until your zone has a button or icon inside it; then every hover over the inner element fires dragleave on the parent and your highlight flashes. Counting dragenter minus dragleave and only flipping the visual at the boundary makes the highlight stable. The dragover listener calling preventDefault() is non-negotiable: without it the browser's default behavior is dropEffect = 'none', so the cursor shows the no-drop icon and drop never fires. The synthetic harness at the bottom is how I unit-test the FSM in CI without spinning up a browser.
// Drag a folder from Finder/Explorer and the browser exposes a webkitGetAsEntry()
// API we can walk recursively. Falls back to dataTransfer.files (which contains
// only the directly-dropped files, NOT the folder contents) on browsers that
// do not implement the API.
async function readEntries(directoryReader) {
return new Promise((resolve, reject) => {
directoryReader.readEntries(resolve, reject);
});
}
async function walkEntry(entry, path = '') {
if (entry.isFile) {
return [{ entry, path: path + entry.name }];
}
const reader = entry.createReader();
const out = [];
let batch;
do {
batch = await readEntries(reader);
for (const child of batch) {
const inner = await walkEntry(child, path + entry.name + '/');
out.push(...inner);
}
} while (batch.length > 0);
return out;
}
async function extractFiles(dataTransfer) {
if (!dataTransfer.items || typeof dataTransfer.items[0]?.webkitGetAsEntry !== 'function') {
return Array.from(dataTransfer.files || []).map((file) => ({ file, path: file.name }));
}
const out = [];
for (const item of dataTransfer.items) {
const entry = item.webkitGetAsEntry();
if (!entry) continue;
const found = await walkEntry(entry);
out.push(...found);
}
return out;
}
// Synthetic DataTransfer that mimics a dropped folder containing two files.
const fakeFile = (name) => ({ name });
const fakeFileEntry = (name) => ({
isFile: true, isDirectory: false, name,
file(cb) { cb(fakeFile(name)); },
});
const fakeDirEntry = (name, children) => ({
isFile: false, isDirectory: true, name,
createReader() {
let returned = false;
return {
readEntries(ok) {
if (returned) return ok([]);
returned = true;
ok(children);
},
};
},
});
const dataTransfer = {
items: [
{ webkitGetAsEntry: () => fakeDirEntry('photos', [fakeFileEntry('a.png'), fakeFileEntry('b.png')]) },
{ webkitGetAsEntry: () => fakeFileEntry('cover.png') },
],
files: [],
};
(async () => {
const found = await extractFiles(dataTransfer);
for (const f of found) console.log('found', f.path);
})();The folder path is what users always assume should work and what almost never does out of the box. dataTransfer.files gives you only the files dropped at the top level, so dropping photos/ gives an empty list. The webkitGetAsEntry API (now part of the File and Directory Entries spec, prefix kept for backwards compat) is the way through. Walking it requires readEntries, which is paginated: it returns up to 100 entries per call and an empty array signals "done", which is why the loop runs until the batch is empty. The fallback path keeps the zone working in browsers that lack the API (Firefox added it in 50, Safari in 11.1).
// In production the zone always needs validation: max file count, max size,
// allowed MIME types. Doing it in the drop handler lets us reject before
// uploading, with a clear error per file.
function makeValidatingDropZone({ maxFiles = 10, maxSizeBytes = 5 * 1024 * 1024, allowed = ['image/png', 'image/jpeg'] }) {
return function onDrop(rawFiles, onAccept, onReject) {
const files = Array.from(rawFiles);
if (files.length > maxFiles) {
return onReject({ kind: 'too_many', max: maxFiles, got: files.length });
}
const accepted = [];
const rejected = [];
for (const f of files) {
if (!allowed.includes(f.type)) {
rejected.push({ file: f, reason: 'mime', detail: f.type || 'unknown' });
continue;
}
if (f.size > maxSizeBytes) {
rejected.push({ file: f, reason: 'size', detail: f.size });
continue;
}
accepted.push(f);
}
if (rejected.length) onReject({ kind: 'partial', rejected, accepted });
if (accepted.length) onAccept(accepted);
};
}
// Drive it with a mixed batch.
const onDrop = makeValidatingDropZone({ maxFiles: 5, maxSizeBytes: 1024 * 1024, allowed: ['image/png'] });
const files = [
{ name: 'logo.png', type: 'image/png', size: 200 * 1024 },
{ name: 'big.png', type: 'image/png', size: 5 * 1024 * 1024 },
{ name: 'doc.pdf', type: 'application/pdf', size: 10 * 1024 },
];
onDrop(
files,
(ok) => console.log('accepted:', ok.map((f) => f.name)),
(err) => console.log('rejected:', JSON.stringify(err)),
);I prefer per-file rejection reasons over a single "upload failed" message because users want to know which file is wrong. The MIME check uses f.type from the browser, which is set from the file extension on Windows and from the actual content-type sniff on macOS, so it is best-effort, not a security boundary. For real security we re-validate on the server with magic bytes; the client check is a UX layer that catches 95% of mistakes without a round trip. The size check happens in JS-land before any byte is read, so a 4GB drop fails instantly instead of hanging the page on an arrayBuffer() call.
// On Safari (and older Edge), dragleave on a child element fires BEFORE the
// matching dragenter on its parent. Using a depth counter alone is not enough
// in those engines; the counter goes negative for one tick.
// Workaround: also track relatedTarget. If relatedTarget is contained within
// the zone, the leave is internal and should be ignored.
function makeBulletproofDropZone(el, onFiles) {
let depth = 0;
const isInside = (node) => {
// In a real DOM: el.contains(node). We stub for the playground.
return node && node.__inZone === true;
};
const handlers = {
dragenter(e) {
e.preventDefault();
// If we just came from another node inside the zone, ignore.
if (isInside(e.relatedTarget)) return;
depth += 1;
if (depth === 1) console.log('zone active');
},
dragleave(e) {
e.preventDefault();
// If we are leaving toward another node inside the zone, ignore.
if (isInside(e.relatedTarget)) return;
depth -= 1;
if (depth <= 0) {
depth = 0;
console.log('zone inactive');
}
},
drop(e) {
e.preventDefault();
depth = 0;
console.log('zone inactive (drop)');
onFiles(Array.from(e.dataTransfer.files));
},
};
return handlers;
}
const onFiles = (files) => console.log('drop:', files.map((f) => f.name));
const handlers = makeBulletproofDropZone({}, onFiles);
const child = { __inZone: true };
const outside = null;
const event = (relatedTarget, files = []) => ({
preventDefault() {},
relatedTarget,
dataTransfer: { files },
});
// Sequence that broke on Safari before the relatedTarget guard:
handlers.dragenter(event(outside)); // entered from outside -> active
handlers.dragleave(event(child)); // leaving toward an inner element -> ignore
handlers.dragenter(event(child)); // entering inner element -> ignore
handlers.dragleave(event(outside)); // genuinely leaving -> inactive
handlers.dragenter(event(outside)); // re-enter
handlers.drop(event(outside, [{ name: 'photo.png' }]));I added the relatedTarget guard after a Safari user reported the upload affordance flickering on every hover. In Safari the event order on entering a child is dragleave (parent, relatedTarget=child) -> dragenter (parent, relatedTarget=oldChild), so the depth counter dips negative for a frame and the visual flickers. Checking whether relatedTarget is contained in the zone short-circuits both events and keeps the visual stable. Chrome and Firefox emit the events in the more sensible dragenter (child) -> dragleave (parent, relatedTarget=child) order, so the guard is a no-op there. This is the entire reason I keep my own drop zone instead of pulling in react-dropzone: the bug surface is small enough to own.
