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.

JavaScript
Frontend
4 snippets
js-dom
code-template
frontend
utility
isabellarashid

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 files

The 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.