DEV Community

Cover image for How to Build a Drag-and-Drop File Dropzone in React & Next.js (With Tailwind CSS) — Line by Line
Muhammad Hamid Raza
Muhammad Hamid Raza

Posted on

How to Build a Drag-and-Drop File Dropzone in React & Next.js (With Tailwind CSS) — Line by Line

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode
  • 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 })
Enter fullscreen mode Exit fullscreen mode
  • 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 — if true, user can select multiple files. Default is false (single file only).

4. State Variables

const [isDragging, setIsDragging] = useState(false);
const [droppedFiles, setDroppedFiles] = useState([]);
const [error, setError] = useState("");
const inputRef = useRef(null);
Enter fullscreen mode Exit fullscreen mode
  • 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 call inputRef.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]
);
Enter fullscreen mode Exit fullscreen mode

This is your security guard. 🔒

  • If multiple is false but the user dropped 3 files, it rejects immediately with an error message.
  • Then it checks file types. It splits the accept string (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.type checks 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]
);
Enter fullscreen mode Exit fullscreen mode

This is the central dispatcher for all file activity — whether the user drags, drops, or clicks to pick files.

  • Array.from(files) converts the native FileList object (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 — return exits the function.
  • If files pass validation, it saves them in state and calls the parent's onFilesSelected callback so the parent knows what files were selected.

7. handleDragOver

const handleDragOver = (e) => {
  e.preventDefault();
  e.stopPropagation();
  setIsDragging(true);
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

This fires when the user releases the file over the dropzone.

  • Again, e.preventDefault() prevents the browser from opening the file.
  • e.dataTransfer.files is where the browser stores the dropped files. dataTransfer is 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);
  }
};
Enter fullscreen mode Exit fullscreen mode
  • handleClick programmatically triggers a click on the hidden <input type="file">. The ?. is optional chaining — just in case the ref hasn't attached yet.
  • handleInputChange handles files selected through the file picker dialog. e.target.files gives 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);
};
Enter fullscreen mode Exit fullscreen mode

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"
    }
  `}
>
Enter fullscreen mode Exit fullscreen mode

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 isDragging is true, 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}
/>
Enter fullscreen mode Exit fullscreen mode
  • ref={inputRef} — connects this element to our inputRef so we can trigger it from handleClick.
  • type="file" — standard HTML file input.
  • accept and multiple are passed from props, controlling what's allowed.
  • className="hidden" — the input is invisible. The div above 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>
))}
Enter fullscreen mode Exit fullscreen mode
  • We loop over droppedFiles and render each one as a list item.
  • file.name gives the original filename.
  • file.size is in bytes, so we divide by 1024 to convert to KB and use .toFixed(1) to show one decimal place.
  • truncate is a Tailwind class that adds text-overflow: ellipsis — long filenames won't break the layout.

15. The Remove Button

<button
  onClick={(e) => {
    e.stopPropagation();
    handleRemoveFile(index);
  }}
  ...
>
  ×
</button>
Enter fullscreen mode Exit fullscreen mode

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() on dragover and drop. Without it, the browser does its own thing.
  • Convert FileList to 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.type for validation — it can be spoofed. Always validate on the server too.
  • Don't skip the multiple prop 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)