File Dropzone
Drag-and-drop upload area with size limits and a file list.
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>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/file-dropzone.tsxcomponents/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> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.