File Upload Dropzone
A real drag-and-drop file zone with a live dragover state and file chips.
- Category
- Components
- Added
- 2026-08-23
- Deps
- 2
- Updated
- 2026-09-01
Drag files or click to browse
PNG, PDF up to 10MB
Props
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { UploadCloud, FileText, X } from "lucide-react";
export function FileUploadDropzone({
hint = "PNG, PDF up to 10MB",
accept = [".png", ".pdf"],
maxMb = 10,
maxBytes,
onFiles,
}: {
hint?: string;
/** Extensions actually enforced. Keep this in step with `hint`. */
accept?: string[];
/** Size ceiling in megabytes. Keep this in step with the hint. */
maxMb?: number;
/** Exact byte ceiling. Overrides maxMb when given. */
maxBytes?: number;
onFiles?: (files: File[]) => void;
}) {
const sizeLimit = maxBytes ?? maxMb * 1024 * 1024;
const [dragging, setDragging] = useState(false);
const [files, setFiles] = useState<File[]>([]);
function addFiles(list: FileList | null) {
if (!list) return;
// The hint states a limit, so the component enforces one. A dropzone whose
// caption claims "PNG, PDF up to 10MB" while accepting a 2GB .exe teaches
// the copier that the constraint is handled when it is not. Client-side
// checks are a UX gate, never a security boundary — validate on the server
// too, since anything here can be bypassed.
const next = Array.from(list).filter(
(f) => f.size <= sizeLimit && (!accept.length || accept.some((t) => f.name.toLowerCase().endsWith(t))),
);
if (!next.length) return;
setFiles((prev) => [...prev, ...next]);
onFiles?.(next);
}
return (
<div className="w-full max-w-sm">
<label
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
addFiles(e.dataTransfer.files);
}}
className={`flex cursor-pointer flex-col items-center gap-2 rounded-2xl border-2 border-dashed px-6 py-10 text-center transition-colors ${
dragging ? "border-red-500 bg-red-50" : "border-neutral-200 hover:border-neutral-400"
}`}
>
<input
type="file"
multiple
accept={accept.join(",")}
className="hidden"
onChange={(e) => addFiles(e.target.files)}
/>
<motion.div animate={{ y: dragging ? -3 : 0, scale: dragging ? 1.08 : 1 }}>
<UploadCloud size={26} className={dragging ? "text-red-500" : "text-neutral-400"} />
</motion.div>
<p className="text-sm font-medium">{dragging ? "Drop it here" : "Drag files or click to browse"}</p>
<p className="text-xs text-neutral-400">{hint}</p>
</label>
{files.length > 0 && (
<ul className="mt-3 flex flex-col gap-1.5">
<AnimatePresence initial={false}>
{files.map((file, i) => (
<motion.li
key={`${file.name}-${i}`}
initial={{ opacity: 0, x: -8, height: 0 }}
animate={{ opacity: 1, x: 0, height: "auto" }}
exit={{ opacity: 0, x: 8, height: 0 }}
className="flex items-center gap-2 overflow-hidden rounded-lg border border-neutral-200 px-3 py-2 text-xs"
>
<FileText size={13} className="shrink-0 text-neutral-400" />
<span className="flex-1 truncate">{file.name}</span>
<button
aria-label={`Remove ${file.name}`}
onClick={() => setFiles((prev) => prev.filter((_, idx) => idx !== i))}
className="shrink-0 text-neutral-400 hover:text-red-500"
>
<X size={13} />
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
)}
</div>
);
}
About this component
Most "drop zones" on the web are just a div with an onClick that pops the OS file picker — no real drag-and-drop, no dataTransfer handling, a click target wearing a costume. This one reads actual DragEvent.dataTransfer.files on drop and flips a real dragging boolean from onDragOver/onDragLeave, so the accent border and the lifted, scaled-up icon only appear during a genuine drag, not a CSS hover fake-out. Reach for it over a bare input[type=file] when the upload is a primary action worth calling out visually; it now enforces the extension and size limits its own hint advertises — a caption promising "PNG, PDF up to 10MB" over a handler that accepts a 2GB executable teaches the copier that the constraint is handled when it is not — but reach for a library like react-dropzone once you need multiple coordinated zones or chunked uploads. The label element wraps the hidden input, which is what lets clicking anywhere in the zone open the picker without extra JS. The onFiles callback fires with only the newly added files per event, not the running list, so a parent that needs the full set has to accumulate it itself. There is no dedupe. And the type and size checks are a UX gate, never a security boundary: anything client-side can be bypassed, so the server must validate independently.
Curator’s note
The preview simulates a drop with a synthetic filename when the sandboxed iframe-less card doesn't receive real OS drag data (e.g. a click), so the interaction always has something to show — the shipped code above reads real File objects from the drop/change events.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Floating Label Input
componentsA text input whose label floats from placeholder position into a caption on focus.
Date Range Picker
componentsA click-twice range calendar with a live hover preview and quick presets.
Multi-Select Combobox
componentsA chip-and-search combobox with full keyboard control and a selection cap.