Ever tried to upload a file on a website and the experience felt like dragging a suitcase up a broken escalator? 😅 A clunky <input type="file"> button just doesn't cut it anymore. Users expect a smooth, modern drag-and-drop experience — and as a developer, you should know how to build one.
In this post, we're going to build a fully functional, reusable Dropzone component in React (works in Next.js too) styled beautifully with Tailwind CSS. Every single line of code will be explained so you know exactly what's happening and why.
Ready to make file uploading feel like a superpower? Let's go. 🚀
What Is a Dropzone?
A Dropzone is a UI area where users can either drag and drop files or click to browse files from their device. Think of it like a designated landing pad — you either drop the package from above or walk up and hand it in.
Instead of just clicking a tiny "Choose File" button, users can grab a file from their desktop and drop it directly into a highlighted area on the page. It's more intuitive, more visual, and honestly just looks much better.
Why Build Your Own Dropzone?
You might be thinking: "Can't I just use react-dropzone?"
You absolutely can. But knowing how to build one from scratch means:
- You understand the browser's Drag-and-Drop API deeply
- You're not adding unnecessary npm packages for simple use cases
- You can customize every pixel and every behavior
- You can extend it however you need — file previews, progress bars, validations
And honestly, after this post, using any library will make even more sense because you'll understand what's happening under the hood.
Why This Matters for Real Projects
Here are situations where a good Dropzone actually matters:
- Profile picture uploads — Instagram-style image selection
- Document upload portals — legal, medical, or HR apps
- Portfolio builders — drag project screenshots in
- Form builders — attach files to a contact or support form
- AI tools — let users upload images or PDFs to analyze
A broken or ugly file upload experience kills trust fast. A smooth one builds it.
Benefits of a Custom Dropzone Component
- ✅ Reusable — Build once, use everywhere in your project
- ✅ Fully controlled — You decide what file types are accepted
- ✅ Visual feedback — Users see drag state, file name, errors
- ✅ No extra dependencies — Pure React + Tailwind
- ✅ Accessible — Works with click too, not just drag
- ✅ Next.js compatible — Just add
"use client"and you're good
The Complete Copy-Paste Component
Before we explain everything line by line, here's the complete component you can copy-paste directly into your project. Just drop it in your components/ folder as Dropzone.jsx (or Dropzone.tsx for TypeScript users).
"use client"; // Required in Next.js App Router to enable React hooks
import { useState, useRef, useCallback } from "react";
export default function Dropzone({ onFilesSelected, accept = "*", multiple = false }) {
const [isDragging, setIsDragging] = useState(false);
const [droppedFiles, setDroppedFiles] = useState([]);
const [error, setError] = useState("");
const inputRef = useRef(null);
const validateFiles = useCallback(
(files) => {
if (!multiple && files.length > 1) {
setError("Only one file is allowed.");
return false;
}
if (accept !== "*") {
const allowedTypes = accept.split(",").map((t) => t.trim());
const allValid = Array.from(files).every((file) =>
allowedTypes.some((type) => file.type === type || file.name.endsWith(type))
);
if (!allValid) {
setError(`Invalid file type. Allowed: ${accept}`);
return false;
}
}
setError("");
return true;
},
[accept, multiple]
);
const handleFiles = useCallback(
(files) => {
const fileArray = Array.from(files);
if (!validateFiles(fileArray)) return;
setDroppedFiles(fileArray);
if (onFilesSelected) onFilesSelected(fileArray);
},
[validateFiles, onFilesSelected]
);
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleFiles(files);
}
};
const handleClick = () => {
inputRef.current?.click();
};
const handleInputChange = (e) => {
const files = e.target.files;
if (files && files.length > 0) {
handleFiles(files);
}
};
const handleRemoveFile = (index) => {
const updated = droppedFiles.filter((_, i) => i !== index);
setDroppedFiles(updated);
if (onFilesSelected) onFilesSelected(updated);
};
return (
<div className="w-full max-w-xl mx-auto">
{/* Dropzone Area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
className={`
flex flex-col items-center justify-center
border-2 border-dashed rounded-2xl
p-10 cursor-pointer transition-all duration-300
${isDragging
? "border-orange-400 bg-orange-50 scale-[1.02]"
: "border-gray-300 bg-gray-50 hover:border-orange-300 hover:bg-orange-50"
}
`}
>
{/* Icon */}
<div className="mb-4 text-5xl select-none">
{isDragging ? "📂" : "📁"}
</div>
{/* Text */}
<p className="text-gray-600 text-base font-medium text-center">
{isDragging
? "Release to drop your files here!"
: "Drag & drop files here, or click to browse"}
</p>
<p className="text-gray-400 text-sm mt-1 text-center">
{accept === "*" ? "All file types accepted" : `Accepted: ${accept}`}
</p>
{/* Hidden File Input */}
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
className="hidden"
onChange={handleInputChange}
/>
</div>
{/* Error Message */}
{error && (
<p className="mt-3 text-sm text-red-500 font-medium text-center">
⚠️ {error}
</p>
)}
{/* File List */}
{droppedFiles.length > 0 && (
<ul className="mt-4 space-y-2">
{droppedFiles.map((file, index) => (
<li
key={index}
className="flex items-center justify-between bg-white border border-gray-200 rounded-xl px-4 py-3 shadow-sm"
>
<div className="flex items-center gap-3 overflow-hidden">
<span className="text-xl">📄</span>
<div className="overflow-hidden">
<p className="text-sm font-medium text-gray-700 truncate">{file.name}</p>
<p className="text-xs text-gray-400">{(file.size / 1024).toFixed(1)} KB</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
handleRemoveFile(index);
}}
className="ml-4 text-gray-400 hover:text-red-500 transition-colors text-lg font-bold"
aria-label="Remove file"
>
×
</button>
</li>
))}
</ul>
)}
</div>
);
}
How to Use the Component
Paste this wherever you want in your page or form:
// pages/index.jsx or app/page.jsx
import Dropzone from "@/components/Dropzone";
export default function Home() {
const handleFiles = (files) => {
console.log("Selected files:", files);
// Send to server, preview, etc.
};
return (
<main className="min-h-screen flex items-center justify-center bg-gray-100 p-6">
<Dropzone
onFilesSelected={handleFiles}
accept="image/png, image/jpeg, image/webp"
multiple={true}
/>
</main>
);
}
That's it. Drop images in — you'll see them listed below the zone. 🎉
Line-by-Line Code Explanation
Now let's break down every important part of the component so you understand the "why" behind each line.
1. "use client"
"use client";
This is Next.js App Router specific. It tells Next.js that this component runs in the browser, not on the server. You need this because we use React hooks like useState and event listeners. If you're using Next.js Pages Router or plain React, you can remove this line — it's not needed there.
2. Importing Hooks
import { useState, useRef, useCallback } from "react";
-
useState— manages state: is the user dragging? what files are dropped? any errors? -
useRef— creates a direct reference to the hidden<input type="file">element so we can trigger it programmatically when the user clicks anywhere on the dropzone -
useCallback— wraps functions so they don't get recreated on every re-render, which is important when those functions are used as dependencies in other hooks
3. Component Props
export default function Dropzone({ onFilesSelected, accept = "*", multiple = false })
-
onFilesSelected— a callback function the parent passes in to receive the selected files. Think of it like handing a delivery to the right address. -
accept— which file types are allowed. Defaults to"*"meaning anything goes. You can pass"image/png, image/jpeg"to restrict to images. -
multiple— iftrue, user can select multiple files. Default isfalse(single file only).
4. State Variables
const [isDragging, setIsDragging] = useState(false);
const [droppedFiles, setDroppedFiles] = useState([]);
const [error, setError] = useState("");
const inputRef = useRef(null);
-
isDragging— tracks whether a file is currently being dragged over the dropzone. Used to change the visual appearance dynamically. -
droppedFiles— stores the list of files the user has dropped or selected. This is what gets shown in the file list below. -
error— stores any validation error message (like "wrong file type"). -
inputRef— points directly to the hidden<input>element. When the user clicks the dropzone, we callinputRef.current.click()to open the file picker — even though the input itself is invisible.
5. The validateFiles Function
const validateFiles = useCallback(
(files) => {
if (!multiple && files.length > 1) {
setError("Only one file is allowed.");
return false;
}
if (accept !== "*") {
const allowedTypes = accept.split(",").map((t) => t.trim());
const allValid = Array.from(files).every((file) =>
allowedTypes.some((type) => file.type === type || file.name.endsWith(type))
);
if (!allValid) {
setError(`Invalid file type. Allowed: ${accept}`);
return false;
}
}
setError("");
return true;
},
[accept, multiple]
);
This is your security guard. 🔒
- If
multipleisfalsebut the user dropped 3 files, it rejects immediately with an error message. - Then it checks file types. It splits the
acceptstring (e.g.,"image/png, image/jpeg") into an array and checks whether each file matches. - The
.every()means all files must pass. One bad apple ruins the whole batch. -
file.typechecks the MIME type (e.g.,"image/png").file.name.endsWith(type)is a fallback for checking extensions (e.g.,.pdf). - If everything's fine, it clears the error and returns
true.
6. The handleFiles Function
const handleFiles = useCallback(
(files) => {
const fileArray = Array.from(files);
if (!validateFiles(fileArray)) return;
setDroppedFiles(fileArray);
if (onFilesSelected) onFilesSelected(fileArray);
},
[validateFiles, onFilesSelected]
);
This is the central dispatcher for all file activity — whether the user drags, drops, or clicks to pick files.
-
Array.from(files)converts the nativeFileListobject (what the browser gives you) into a regular JavaScript array. FileList is not an array, so you can't use.map()or.filter()on it directly. - It runs validation first. If validation fails, it stops right there —
returnexits the function. - If files pass validation, it saves them in state and calls the parent's
onFilesSelectedcallback so the parent knows what files were selected.
7. handleDragOver
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
This fires continuously as the user hovers a file over the dropzone.
-
e.preventDefault()is critical. Without it, the browser would open the file itself (imagine dropping a PDF and suddenly the browser navigates to a PDF viewer). This stops that default behavior. -
e.stopPropagation()prevents the event from bubbling up to parent elements, which could cause unexpected behavior. -
setIsDragging(true)activates the "active drag" visual state.
8. handleDragLeave
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
Fires when the user's dragged file leaves the dropzone area. Sets isDragging back to false so the highlighted state disappears. Simple but necessary.
9. handleDrop
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
handleFiles(files);
}
};
This fires when the user releases the file over the dropzone.
- Again,
e.preventDefault()prevents the browser from opening the file. -
e.dataTransfer.filesis where the browser stores the dropped files.dataTransferis the native browser API specifically for drag-and-drop data. - We check that files exist and are not empty, then pass them to
handleFiles.
10. handleClick and handleInputChange
const handleClick = () => {
inputRef.current?.click();
};
const handleInputChange = (e) => {
const files = e.target.files;
if (files && files.length > 0) {
handleFiles(files);
}
};
-
handleClickprogrammatically triggers a click on the hidden<input type="file">. The?.is optional chaining — just in case the ref hasn't attached yet. -
handleInputChangehandles files selected through the file picker dialog.e.target.filesgives us the FileList from the native input.
Both paths — drag-drop and click-to-browse — eventually call the same handleFiles function. Single source of truth. ✅
11. handleRemoveFile
const handleRemoveFile = (index) => {
const updated = droppedFiles.filter((_, i) => i !== index);
setDroppedFiles(updated);
if (onFilesSelected) onFilesSelected(updated);
};
Removes a file from the list by index. The filter creates a new array excluding the file at the given index. We also notify the parent with the updated list via onFilesSelected.
12. The JSX — Dropzone Area
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
className={`
flex flex-col items-center justify-center
border-2 border-dashed rounded-2xl
p-10 cursor-pointer transition-all duration-300
${isDragging
? "border-orange-400 bg-orange-50 scale-[1.02]"
: "border-gray-300 bg-gray-50 hover:border-orange-300 hover:bg-orange-50"
}
`}
>
This div is the entire interactive surface.
- Four event listeners handle dragging, leaving, dropping, and clicking.
-
flex flex-col items-center justify-center— centers everything inside vertically and horizontally. -
border-2 border-dashed— the classic dropzone dashed border look. -
rounded-2xl— smooth, modern rounded corners. -
cursor-pointer— signals to the user that this area is clickable. -
transition-all duration-300— smooth visual transitions when state changes. - The conditional class: when
isDraggingistrue, the border goes orange, the background gets a soft orange tint, and the box subtly scales up (scale-[1.02]). It gives satisfying visual feedback that "yes, you can drop here."
13. The Hidden Input
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
className="hidden"
onChange={handleInputChange}
/>
-
ref={inputRef}— connects this element to ourinputRefso we can trigger it fromhandleClick. -
type="file"— standard HTML file input. -
acceptandmultipleare passed from props, controlling what's allowed. -
className="hidden"— the input is invisible. Thedivabove acts as the visual replacement. -
onChange— fires when the user selects files via the file picker dialog.
14. File List
{droppedFiles.map((file, index) => (
<li key={index} className="flex items-center justify-between bg-white border border-gray-200 rounded-xl px-4 py-3 shadow-sm">
...
<p className="text-sm font-medium text-gray-700 truncate">{file.name}</p>
<p className="text-xs text-gray-400">{(file.size / 1024).toFixed(1)} KB</p>
...
</li>
))}
- We loop over
droppedFilesand render each one as a list item. -
file.namegives the original filename. -
file.sizeis in bytes, so we divide by1024to convert to KB and use.toFixed(1)to show one decimal place. -
truncateis a Tailwind class that addstext-overflow: ellipsis— long filenames won't break the layout.
15. The Remove Button
<button
onClick={(e) => {
e.stopPropagation();
handleRemoveFile(index);
}}
...
>
×
</button>
e.stopPropagation() here is important. Without it, clicking the remove button would also trigger the parent div's onClick, which would open the file picker. We stop the click from bubbling up.
Comparison: Native Input vs Custom Dropzone
| Feature | <input type="file"> |
Custom Dropzone |
|---|---|---|
| Drag & Drop | ❌ | ✅ |
| Visual feedback | ❌ | ✅ |
| File preview | ❌ | ✅ |
| Custom styling | Very limited | Fully customizable |
| Remove individual files | ❌ | ✅ |
| File type validation | Basic | Custom & detailed |
| UX quality | Basic | Modern & polished |
Best Tips — Do's & Don'ts
✅ Do's:
- Always call
e.preventDefault()ondragoveranddrop. Without it, the browser does its own thing. - Convert
FileListto an array before doing anything useful with it. - Give clear visual feedback when the user is dragging over the zone.
- Always notify the parent component via a callback so it can handle uploads.
- Use
e.stopPropagation()on nested click handlers to prevent accidental triggers.
❌ Don'ts:
- Don't forget
"use client"in Next.js App Router. Hooks won't work without it. - Don't rely only on
file.typefor validation — it can be spoofed. Always validate on the server too. - Don't skip the
multipleprop logic if your backend only accepts one file. - Don't make the dropzone too small — users need room to drop.
Common Mistakes People Make
1. Forgetting e.preventDefault() on dragover
This is the most common mistake. If you skip it, the drop event will never fire because the browser cancels it. Every drag-and-drop implementation needs this.
2. Not converting FileList to an Array
FileList is a browser-native object. It looks like an array but it's not. Calling .map() or .filter() directly on it will throw an error. Always use Array.from(files) first.
3. Skipping the stopPropagation on the remove button
If the remove button click bubbles up to the parent div, it also triggers the file picker. Very confusing for users. Always stop it.
4. Only supporting drag-and-drop but not click
Some users (especially on trackpads) find drag-and-drop annoying or harder. Always support both interactions. Our component does this — clicking anywhere on the zone opens the file picker.
5. Doing validation only on the frontend
Frontend validation is for UX. Server-side validation is for security. Never trust a file just because the browser said it was valid. Always re-validate on the backend.
Conclusion
Building a custom Dropzone component from scratch is one of those things that teaches you a lot in a short amount of time. You learn about the browser's Drag-and-Drop API, React state management, refs, callbacks, and Tailwind styling — all in one component. 🎯
Here's what we covered:
- How drag-and-drop events work in the browser
- How to build a reusable Dropzone component with clean props
- How Tailwind classes make visual state changes effortless
- How to validate files, remove them, and pass them to the parent
- What every single line is doing and why it's there
Now you have a clean, copy-paste-ready Dropzone component that you can drop into any React or Next.js project and customize to your heart's content.
Want more practical React and Next.js tutorials like this one? Head over to hamidrazadev.com — there's a lot more waiting for you there. 💡
If this post saved you time or cleared up confusion, share it with a dev friend or your team. They'll thank you for it. 😊
Top comments (0)