Drag and Drop — File Upload with Visual Drop Zone

Drag-and-drop file upload provides a more natural interaction for bulk uploads — dropping multiple images onto the browser window feels faster and more intuitive than clicking “Browse” multiple times. The HTML5 Drag and Drop API exposes four events on the drop target: dragenter, dragover, dragleave, and drop. React wraps these as onDragEnter, onDragOver, onDragLeave, and onDrop. The critical detail: you must call e.preventDefault() on dragover to allow dropping (browsers block drops by default).

DropZone Component

import { useState, useRef, useCallback } from "react";

const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE_BYTES = 5 * 1024 * 1024;

function DropZone({ onFiles, multiple = false, accept = ACCEPTED_TYPES }) {
    const [isDraggingOver, setIsDraggingOver] = useState(false);
    const [errors, setErrors]                 = useState([]);
    const inputRef                            = useRef(null);
    const dragCounter                         = useRef(0);   // tracks nested drag events

    function validateFiles(fileList) {
        const valid   = [];
        const invalid = [];

        Array.from(fileList).forEach((file) => {
            if (!accept.includes(file.type)) {
                invalid.push(`${file.name}: unsupported format`);
            } else if (file.size > MAX_SIZE_BYTES) {
                invalid.push(`${file.name}: too large (max 5 MB)`);
            } else {
                valid.push(file);
            }
        });

        return { valid, invalid };
    }

    const handleDragEnter = useCallback((e) => {
        e.preventDefault();
        dragCounter.current += 1;   // increment counter (handles nested children)
        if (e.dataTransfer.items?.length > 0) {
            setIsDraggingOver(true);
        }
    }, []);

    const handleDragOver = useCallback((e) => {
        e.preventDefault();   // ← required to allow dropping
        e.dataTransfer.dropEffect = "copy";
    }, []);

    const handleDragLeave = useCallback((e) => {
        e.preventDefault();
        dragCounter.current -= 1;
        if (dragCounter.current === 0) {
            setIsDraggingOver(false);   // only hide when fully left the zone
        }
    }, []);

    const handleDrop = useCallback((e) => {
        e.preventDefault();
        dragCounter.current = 0;
        setIsDraggingOver(false);

        const { valid, invalid } = validateFiles(e.dataTransfer.files);
        setErrors(invalid);

        if (valid.length > 0) {
            onFiles(multiple ? valid : [valid[0]]);
        }
    }, [onFiles, multiple, accept]);

    const handleInputChange = useCallback((e) => {
        const { valid, invalid } = validateFiles(e.target.files);
        setErrors(invalid);
        if (valid.length > 0) {
            onFiles(multiple ? valid : [valid[0]]);
        }
    }, [onFiles, multiple]);

    return (
        <div>
            <div
                onDragEnter={handleDragEnter}
                onDragOver={handleDragOver}
                onDragLeave={handleDragLeave}
                onDrop={handleDrop}
                onClick={() => inputRef.current?.click()}
                className={`border-2 border-dashed rounded-xl p-8 text-center
                            cursor-pointer transition-colors
                            ${isDraggingOver
                                ? "border-blue-500 bg-blue-50"
                                : "border-gray-300 hover:border-gray-400 bg-gray-50"
                            }`}
            >
                {isDraggingOver ? (
                    <p className="text-blue-600 font-medium">Drop to upload</p>
                ) : (
                    <div>
                        <p className="text-gray-600 mb-1">
                            Drag &amp; drop {multiple ? "images" : "an image"} here
                        </p>
                        <p className="text-sm text-gray-400">or click to browse</p>
                        <p className="text-xs text-gray-400 mt-2">
                            JPEG, PNG, WebP — max 5 MB each
                        </p>
                    </div>
                )}
            </div>

            <input
                ref={inputRef}
                type="file"
                accept={accept.join(",")}
                multiple={multiple}
                onChange={handleInputChange}
                className="sr-only"
            />

            {errors.length > 0 && (
                <ul className="mt-2 space-y-1">
                    {errors.map((err, i) => (
                        <li key={i} className="text-red-500 text-sm">{err}</li>
                    ))}
                </ul>
            )}
        </div>
    );
}
Note: The dragCounter ref solves a common drag-and-drop bug: when the user drags over a child element inside the drop zone, dragleave fires on the parent followed immediately by dragenter on the child. Without the counter, isDraggingOver flickers off and on rapidly, causing the highlighted border to flash. The counter tracks how many times the drag has entered minus how many times it has left — only when the counter reaches 0 has the user truly left the entire drop zone.
Tip: Set e.dataTransfer.dropEffect = "copy" in the dragover handler to show the browser’s “copy” cursor icon (a pointer with a plus sign) while hovering over the drop zone. This gives visual feedback that the drop will be accepted. Without it, the browser may show a “no drop” cursor (circle with a line) or the default move cursor, which can mislead users about whether dropping is permitted.
Warning: The drop event handler must call e.preventDefault() to prevent the browser’s default drop behaviour — which is to navigate to the dropped file’s URL (for images) or display the file in the browser tab. Without e.preventDefault() in the drop handler, dragging an image onto your upload zone replaces the current page with the image file!

Common Mistakes

Mistake 1 — Not calling preventDefault on dragover (drop is blocked)

❌ Wrong — drop event never fires:

onDragOver={(e) => { /* no preventDefault */ }}
// Browser won't fire onDrop — it blocks drops on non-configured elements

✅ Correct:

onDragOver={(e) => { e.preventDefault(); }}   // ✓ allows drop

Mistake 2 — No dragCounter (flickers on child element drag)

❌ Wrong — isDraggingOver flickers off and on when hovering over children.

✅ Correct — use a ref counter that increments on enter and decrements on leave.

Quick Reference

Event Must Call Purpose
onDragEnter e.preventDefault() Show drop zone highlight
onDragOver e.preventDefault() (required!) Allow drop to happen
onDragLeave e.preventDefault() Hide highlight (use counter)
onDrop e.preventDefault() (required!) Get files, prevent navigation

🧠 Test Yourself

A user drags an image over your DropZone. The onDragOver handler does not call e.preventDefault(). What happens when they release the mouse?