Build a Drag & Drop Zone
Learn how to build a file upload zone with drag-and-drop support, click-to-browse fallback, upload progress simulation, and visual feedback. A practical interview question that tests the HTML5 Drag and Drop API, File API, and state management.
Table of Contents
Problem Statement
Build a file upload zone that accepts files via drag-and-drop or a click-to-browse fallback. Display each file with its name, size, and a live upload progress bar.
- A drop zone area that accepts dragged files
- Visual feedback when dragging over the zone (border + background change)
- Handle onDragEnter, onDragOver, onDragLeave, and onDrop events
- Click the zone to open a native file picker (hidden input)
- Display dropped files in a list with name, size, and progress
- Simulate upload progress with animated progress bars
- Support removing files from the list
Why this question?
Drag-and-drop file upload is everywhere — Gmail, Slack, Notion, Figma. It tests the HTML5 Drag and Drop API (which is notoriously quirky), the File API, state management for multiple concurrent uploads, and UX polish with visual feedback. Interviewers love it because it's practical and has many edge cases.
Component Anatomy
Break the UI into its visual parts:
Drop Zone
A dashed-border area that listens for drag events. Changes appearance when a file is dragged over it. Also clickable to trigger a file picker.
Hidden File Input
An invisible <input type='file' multiple> triggered programmatically when the drop zone is clicked. Provides the native file picker fallback.
File List
A list of uploaded files showing name, size, progress bar, status (uploading/done/error), and a remove button.
Progress Bar
A thin bar that fills from 0% to 100% as the upload progresses. Color changes based on status: gray (uploading), green (done), red (error).
┌─────────────────────────────────────────────┐ │ │ │ ☁️ Drag & drop files here │ ← Drop zone │ or click to browse │ (dashed border) │ │ └─────────────────────────────────────────────┘ ┌─────────────────────────────────────────────┐ │ [PDF] report.pdf 2.4 MB ✕ │ │ ████████████░░░░ Uploading 68% │ ├─────────────────────────────────────────────┤ │ [PNG] photo.png 1.1 MB ✕ │ │ ████████████████ Complete ✓ │ ├─────────────────────────────────────────────┤ │ [DOC] notes.docx 340 KB ✕ │ │ ████████░░░░░░░░ Upload failed ✗ │ └─────────────────────────────────────────────┘
The Drag Event Lifecycle
The HTML5 Drag and Drop API fires a sequence of events. Understanding the order and which ones need preventDefault() is critical.
onDragEnter — file enters the zone
Fired when a dragged item enters the drop zone. Set isDragging = true to show visual feedback (highlight border, change background).
onDragOver — file is hovering over the zone
Fires continuously while the item is over the zone. You MUST call e.preventDefault() here — without it, the browser won't allow the drop.
onDragLeave — file leaves the zone
Fired when the dragged item leaves the drop zone. Set isDragging = false to remove the visual feedback.
onDrop — file is released
Fired when the user releases the file. Call e.preventDefault() to stop the browser from opening the file. Read files from e.dataTransfer.files.
const [isDragging, setIsDragging] = useState(false); const handleDragEnter = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); // REQUIRED — allows the drop }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); const files = e.dataTransfer.files; // FileList processFiles(files); };
The #1 gotcha: preventDefault on dragOver
If you forget e.preventDefault() on onDragOver, the browser blocks the drop entirely. The onDrop event will never fire. This is the most common mistake in interviews — mention it proactively.
Reading Dropped Files
The e.dataTransfer.files property returns a FileList — an array-like object of File objects. Convert it to a real array and create your state objects.
interface UploadedFile { id: string; name: string; size: number; type: string; status: "uploading" | "done" | "error"; progress: number; } const processFiles = (fileList: FileList) => { // Convert FileList to array of state objects const newFiles: UploadedFile[] = Array.from(fileList).map((f) => ({ id: crypto.randomUUID(), name: f.name, // "report.pdf" size: f.size, // 2457600 (bytes) type: f.type, // "application/pdf" status: "uploading" as const, progress: 0, })); // Add to state setFiles((prev) => [...prev, ...newFiles]); // Start upload for each file newFiles.forEach((file) => { simulateUpload( (progress) => updateFile(file.id, { progress }), () => updateFile(file.id, { progress: 100, status: "done" }), () => updateFile(file.id, { status: "error" }), ); }); };
| File Property | Type | Example |
|---|---|---|
| file.name | string | "report.pdf" |
| file.size | number (bytes) | 2457600 |
| file.type | string (MIME) | "application/pdf" |
| file.lastModified | number (timestamp) | 1705334400000 |
Click-to-Browse Fallback
Not all users know they can drag files. A click-to-browse fallback uses a hidden <input type="file"> triggered programmatically.
const inputRef = useRef<HTMLInputElement>(null); // Hidden input — invisible but functional <input type="file" multiple hidden ref={inputRef} onChange={handleFileSelect} /> // Drop zone click triggers the input <div onClick={() => inputRef.current?.click()}> Drag & drop or click to browse </div> // Read files from the input const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { if (e.target.files?.length) { processFiles(e.target.files); e.target.value = ""; // Reset so same file can be re-selected } };
Why reset e.target.value?
If the user selects the same file twice, the onChange event won't fire because the value hasn't changed. Resetting it to an empty string after each selection ensures the event fires every time. This is a subtle bug interviewers watch for.
Upload Progress Simulation
In interviews you won't have a real server, so simulate upload progress with setInterval. The key is updating individual file state without affecting other files.
function simulateUpload( onProgress: (progress: number) => void, onComplete: () => void, onError: () => void, ) { let progress = 0; const interval = setInterval(() => { progress += Math.random() * 20 + 5; if (progress >= 100) { clearInterval(interval); if (Math.random() < 0.1) { onError(); // 10% chance of failure } else { onProgress(100); onComplete(); } } else { onProgress(Math.min(progress, 99)); } }, 300); return () => clearInterval(interval); // cleanup function }
// Start upload for each file newFiles.forEach((file) => { simulateUpload( // onProgress — update just this file's progress (progress) => setFiles((prev) => prev.map((f) => f.id === file.id ? { ...f, progress, status: "uploading" } : f ) ), // onComplete () => setFiles((prev) => prev.map((f) => f.id === file.id ? { ...f, progress: 100, status: "done" } : f ) ), // onError () => setFiles((prev) => prev.map((f) => f.id === file.id ? { ...f, status: "error" } : f ) ), ); });
Why functional updates (prev => ...)?
Multiple files upload concurrently, each calling setFiles from different intervals. Using setFiles(prev => ...) ensures each update is based on the latest state, not a stale closure. Without this, files would overwrite each other's progress. Always mention this in interviews.
Full Implementation
Here's the complete drag-and-drop zone. Study how the drag events, file processing, progress simulation, and rendering compose together.
"use client"; import { useState, useRef } from "react"; interface UploadedFile { id: string; name: string; size: number; type: string; status: "uploading" | "done" | "error"; progress: number; } export default function DragAndDropZonePage() { const [files, setFiles] = useState<UploadedFile[]>([]); const [isDragging, setIsDragging] = useState(false); const inputRef = useRef<HTMLInputElement>(null); const processFiles = (fileList: FileList) => { const newFiles: UploadedFile[] = Array.from(fileList).map((f) => ({ id: crypto.randomUUID(), name: f.name, size: f.size, type: f.type, status: "uploading" as const, progress: 0, })); setFiles((prev) => [...prev, ...newFiles]); newFiles.forEach((file) => { simulateUpload( (progress) => setFiles((prev) => prev.map((f) => f.id === file.id ? { ...f, progress, status: "uploading" } : f ) ), () => setFiles((prev) => prev.map((f) => f.id === file.id ? { ...f, progress: 100, status: "done" } : f ) ), () => setFiles((prev) => prev.map((f) => f.id === file.id ? { ...f, status: "error" } : f ) ), ); }); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); if (e.dataTransfer.files.length) processFiles(e.dataTransfer.files); }; return ( <div> {/* Drop zone */} <div className={`border-2 border-dashed rounded-xl p-12 cursor-pointer ${ isDragging ? "border-gray-900 bg-gray-50" : "border-gray-300" }`} onDragEnter={(e) => { e.preventDefault(); setIsDragging(true); }} onDragOver={(e) => e.preventDefault()} onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} onDrop={handleDrop} onClick={() => inputRef.current?.click()} > {isDragging ? "Drop files here" : "Drag & drop or click"} </div> <input type="file" multiple hidden ref={inputRef} onChange={(e) => { if (e.target.files?.length) { processFiles(e.target.files); e.target.value = ""; } }} /> {/* File list */} {files.map((file) => ( <div key={file.id}> <span>{file.name} — {formatSize(file.size)}</span> <div style={{ width: `${file.progress}%` }} /> <span>{file.status}</span> <button onClick={() => setFiles((prev) => prev.filter((f) => f.id !== file.id)) }>✕</button> </div> ))} </div> ); }
Visual drag feedback
isDragging state toggles border color and background on the drop zone. Text changes from 'Drag & drop' to 'Drop files here' for clear affordance.
Concurrent upload tracking
Each file has its own progress and status. Functional state updates (prev => ...) ensure concurrent intervals don't overwrite each other.
Click-to-browse fallback
Hidden file input triggered by clicking the drop zone. Input value is reset after selection so the same file can be re-added.
File type validation
Check file.type or file extension against an allowed list. Show an error for rejected files. Add an accept attribute to the hidden input.
Max file size limit
Check file.size against a maximum (e.g., 10MB). Reject oversized files with a user-friendly error message before starting the upload.
Cancel in-progress uploads
Store the cleanup function returned by simulateUpload. Call it when the user removes a file that's still uploading to clear the interval.
Common Interview Follow-up Questions
After building the drop zone, interviewers explore edge cases and production concerns:
Q:Why does onDragLeave fire when hovering over child elements?
A: Drag events bubble. When you hover over a child element inside the drop zone, onDragLeave fires on the parent (you 'left' it for the child). Fix this by checking e.currentTarget.contains(e.relatedTarget) — if the related target is still inside the zone, ignore the leave event. Or use a counter: increment on enter, decrement on leave, only set isDragging=false when counter hits 0.
Q:How would you implement a real upload with progress?
A: Use XMLHttpRequest (not fetch) because it supports upload progress events via xhr.upload.onprogress. The event gives you e.loaded and e.total to calculate percentage. Fetch API doesn't support upload progress natively — you'd need a ReadableStream workaround.
Q:How would you handle file type restrictions?
A: Add an accept attribute to the hidden input (e.g., accept='.pdf,.png,.jpg'). For drag-and-drop, check file.type or the extension in processFiles and reject invalid files with an error message. Show accepted types in the drop zone UI.
Q:How would you add a file size limit?
A: Check file.size in processFiles before adding to state. If it exceeds the limit, add the file with status 'error' and a message like 'File too large (max 10MB)'. Or filter it out entirely and show a toast notification.
Q:How would you prevent duplicate files?
A: Before adding a new file, check if a file with the same name and size already exists in state. If so, skip it or show a warning. For stricter deduplication, compute a hash of the file content using the Web Crypto API (FileReader + crypto.subtle.digest).
Q:How would you support folder drops?
A: Use e.dataTransfer.items instead of e.dataTransfer.files. Each item has a webkitGetAsEntry() method that returns a FileSystemEntry. Check if it's a directory with isDirectory, then recursively read its contents with createReader().readEntries(). This is a non-standard API but widely supported.
Q:How would you add drag-and-drop reordering of the file list?
A: Make each file row draggable with draggable='true'. Track the dragged item index and the drop target index. On drop, reorder the array by removing the item from its old position and inserting it at the new position. Use onDragStart, onDragOver (with preventDefault), and onDrop on each row.
Q:What about accessibility?
A: The click-to-browse fallback is essential — keyboard users can't drag and drop. Ensure the drop zone is focusable (tabIndex=0) and responds to Enter/Space to open the file picker. Add aria-label describing the drop zone. Announce upload status changes with aria-live='polite'.
Ready to build it yourself?
We've set up the types, helpers, and UI shell. Implement the drag events, file processing, and progress rendering from scratch.
Built for developers, by developers. Happy coding! 🚀