File Dropzone

Drag-and-drop upload area with size limits and a file list.

    Installation

    1. Install the dependencies

      $ npm i motion lucide-react
    2. Copy the source into components/ui/file-dropzone.tsx

      components/ui/file-dropzone.tsx
      "use client";
      
      import { AnimatePresence, motion } from "motion/react";
      import { FileText, UploadCloud, X } from "lucide-react";
      import { useId, useRef, useState, type DragEvent } from "react";
      
      type FileDropzoneProps = {
        accept?: string;
        /** Max size per file in MB */
        maxSizeMb?: number;
        onFiles?: (files: File[]) => void;
      };
      
      function formatSize(bytes: number) {
        return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;
      }
      
      export function FileDropzone({ accept, maxSizeMb = 10, onFiles }: FileDropzoneProps) {
        const [files, setFiles] = useState<File[]>([]);
        const [dragging, setDragging] = useState(false);
        const [error, setError] = useState("");
        const input = useRef<HTMLInputElement>(null);
        const id = useId();
      
        function add(list: FileList | null) {
          if (!list) return;
          const incoming = Array.from(list);
          const tooBig = incoming.filter((f) => f.size > maxSizeMb * 1024 * 1024);
          setError(tooBig.length ? `${tooBig.map((f) => f.name).join(", ")} exceeds ${maxSizeMb} MB` : "");
          const ok = incoming.filter((f) => f.size <= maxSizeMb * 1024 * 1024);
          const next = [...files, ...ok];
          setFiles(next);
          onFiles?.(next);
        }
      
        function onDrop(e: DragEvent) {
          e.preventDefault();
          setDragging(false);
          add(e.dataTransfer.files);
        }
      
        return (
          <div className="w-full">
            <motion.label
              htmlFor={id}
              onDragOver={(e) => {
                e.preventDefault();
                setDragging(true);
              }}
              onDragLeave={() => setDragging(false)}
              onDrop={onDrop}
              animate={{ scale: dragging ? 1.02 : 1, borderColor: dragging ? "#2563eb" : "#e4e4e7", backgroundColor: dragging ? "#eff6ff" : "#ffffff" }}
              className="flex cursor-pointer flex-col items-center rounded-2xl border-2 border-dashed px-6 py-10 text-center"
            >
              <motion.span animate={{ y: dragging ? -6 : 0 }} className="grid size-12 place-items-center rounded-xl bg-blue-50 text-blue-600">
                <UploadCloud className="size-6" />
              </motion.span>
              <p className="mt-4 text-sm font-medium text-zinc-900">{dragging ? "Drop to upload" : "Drag files here or click to browse"}</p>
              <p className="mt-1 text-xs text-zinc-500">Up to {maxSizeMb} MB per file</p>
              <input id={id} ref={input} type="file" multiple accept={accept} className="sr-only" onChange={(e) => add(e.target.files)} />
            </motion.label>
            {error && <p className="mt-2 text-xs text-rose-600">{error}</p>}
            <ul className="mt-3 space-y-2">
              <AnimatePresence initial={false}>
                {files.map((f, i) => (
                  <motion.li key={`${f.name}-${i}`} layout initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, x: 20 }} className="flex items-center gap-3 rounded-xl border border-zinc-200 bg-white px-3 py-2.5">
                    <FileText className="size-4 text-zinc-400" />
                    <span className="flex-1 truncate text-sm text-zinc-700">{f.name}</span>
                    <span className="text-xs text-zinc-400">{formatSize(f.size)}</span>
                    <button
                      type="button"
                      aria-label={`Remove ${f.name}`}
                      onClick={() => {
                        const next = files.filter((_, k) => k !== i);
                        setFiles(next);
                        onFiles?.(next);
                      }}
                      className="text-zinc-400 hover:text-zinc-700"
                    >
                      <X className="size-4" />
                    </button>
                  </motion.li>
                ))}
              </AnimatePresence>
            </ul>
          </div>
        );
      }
      
      export default function Demo() {
        return (
          <div className="w-full max-w-md">
            <FileDropzone />
          </div>
        );
      }
      
    3. Use it

      The file exports the component and a Demo showing typical usage. Import whichever you need.