Upload Dropzone Form
Original · freedrag-and-drop file upload with preview
byWeb InnoventixReact + Tailwind
formuploaddropzoneforms
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/form-upload-dropzone.jsonform-upload-dropzone.tsx
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type DragEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type FileKind = "image" | "pdf" | "doc";
type UploadStatus = "uploading" | "done" | "error";
interface UploadItem {
id: string;
name: string;
size: number;
kind: FileKind;
previewUrl: string | null;
progress: number;
status: UploadStatus;
error: string | null;
}
const ACCEPT = ".png,.jpg,.jpeg,.gif,.webp,.svg,.pdf,.doc,.docx,.txt";
const MAX_BYTES = 15 * 1024 * 1024;
const ALLOWED = new Set([
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"pdf",
"doc",
"docx",
"txt",
]);
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg"]);
function extOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
}
function formatSize(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
const val = bytes / Math.pow(1024, i);
return `${val >= 10 || i === 0 ? Math.round(val) : val.toFixed(1)} ${units[i]}`;
}
function CloudUpIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M7 18a4 4 0 0 1-.9-7.9 5.5 5.5 0 0 1 10.6-1.2A4.2 4.2 0 0 1 17 18" />
<path d="M12 21V11" />
<path d="m8.5 13.5 3.5-3.5 3.5 3.5" />
</svg>
);
}
function ImageGlyph({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="4" width="18" height="16" rx="2.5" />
<circle cx="8.5" cy="9.5" r="1.6" />
<path d="m3.5 17 4.8-4.5a2 2 0 0 1 2.7 0L20.5 20" />
</svg>
);
}
function DocGlyph({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M13 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9z" />
<path d="M13 3v6h6" />
<path d="M9 13h6M9 17h4" />
</svg>
);
}
function PdfGlyph({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M13 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V9z" />
<path d="M13 3v6h6" />
<path d="M8.5 17v-3.2h1a1.1 1.1 0 0 1 0 2.2h-1M13.4 17v-3.2h1.9M13.4 15.5h1.5" />
</svg>
);
}
function FileGlyph({ kind, className }: { kind: FileKind; className?: string }) {
if (kind === "image") return <ImageGlyph className={className} />;
if (kind === "pdf") return <PdfGlyph className={className} />;
return <DocGlyph className={className} />;
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m5 12.5 4.5 4.5L19 7" />
</svg>
);
}
function AlertIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 8v5" />
<path d="M12 16.5h.01" />
<circle cx="12" cy="12" r="9" />
</svg>
);
}
function CloseIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
);
}
function TrashIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m2 0-.7 12a2 2 0 0 1-2 1.9H8.7a2 2 0 0 1-2-1.9L6 7" />
<path d="M10 11v6M14 11v6" />
</svg>
);
}
export default function FormUploadDropzone() {
const reduce = useReducedMotion();
const inputRef = useRef<HTMLInputElement>(null);
const timers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
const urls = useRef<Set<string>>(new Set());
const dragDepth = useRef(0);
const idSeed = useRef(0);
const [files, setFiles] = useState<UploadItem[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [announce, setAnnounce] = useState("");
const startUpload = useCallback((id: string) => {
const timer = setInterval(() => {
setFiles((prev) =>
prev.map((f) => {
if (f.id !== id || f.status !== "uploading") return f;
const next = Math.min(100, f.progress + Math.random() * 17 + 7);
if (next >= 100) {
const t = timers.current.get(id);
if (t !== undefined) {
clearInterval(t);
timers.current.delete(id);
}
return { ...f, progress: 100, status: "done" };
}
return { ...f, progress: next };
}),
);
}, 260);
timers.current.set(id, timer);
}, []);
const addFiles = useCallback(
(list: FileList) => {
const incoming = Array.from(list);
if (incoming.length === 0) return;
const created: UploadItem[] = incoming.map((file) => {
const ext = extOf(file.name);
const id = `fud-${Date.now().toString(36)}-${idSeed.current++}`;
const kind: FileKind =
IMAGE_EXT.has(ext) ? "image" : ext === "pdf" ? "pdf" : "doc";
let status: UploadStatus = "uploading";
let error: string | null = null;
if (!ALLOWED.has(ext)) {
status = "error";
error = "Unsupported format";
} else if (file.size > MAX_BYTES) {
status = "error";
error = "Over the 15 MB limit";
}
let previewUrl: string | null = null;
if (status !== "error" && kind === "image") {
previewUrl = URL.createObjectURL(file);
urls.current.add(previewUrl);
}
return {
id,
name: file.name,
size: file.size,
kind,
previewUrl,
progress: 0,
status,
error,
};
});
setFiles((prev) => [...created, ...prev]);
created.forEach((item) => {
if (item.status === "uploading") startUpload(item.id);
});
setAnnounce(
`${created.length} file${created.length > 1 ? "s" : ""} added to the upload queue`,
);
},
[startUpload],
);
const removeFile = useCallback((item: UploadItem) => {
const t = timers.current.get(item.id);
if (t !== undefined) {
clearInterval(t);
timers.current.delete(item.id);
}
if (item.previewUrl) {
URL.revokeObjectURL(item.previewUrl);
urls.current.delete(item.previewUrl);
}
setFiles((prev) => prev.filter((f) => f.id !== item.id));
setAnnounce(`${item.name} removed`);
}, []);
const clearAll = useCallback(() => {
timers.current.forEach((t) => clearInterval(t));
timers.current.clear();
urls.current.forEach((u) => URL.revokeObjectURL(u));
urls.current.clear();
setFiles([]);
setAnnounce("All files cleared");
}, []);
useEffect(() => {
const activeTimers = timers.current;
const activeUrls = urls.current;
return () => {
activeTimers.forEach((t) => clearInterval(t));
activeTimers.clear();
activeUrls.forEach((u) => URL.revokeObjectURL(u));
activeUrls.clear();
};
}, []);
const openPicker = useCallback(() => {
inputRef.current?.click();
}, []);
const onInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = "";
},
[addFiles],
);
const onDragEnter = useCallback((e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
dragDepth.current += 1;
setIsDragging(true);
}, []);
const onDragOver = useCallback((e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
}, []);
const onDragLeave = useCallback((e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
dragDepth.current -= 1;
if (dragDepth.current <= 0) {
dragDepth.current = 0;
setIsDragging(false);
}
}, []);
const onDrop = useCallback(
(e: DragEvent<HTMLButtonElement>) => {
e.preventDefault();
dragDepth.current = 0;
setIsDragging(false);
if (e.dataTransfer.files) addFiles(e.dataTransfer.files);
},
[addFiles],
);
const total = files.length;
const doneCount = files.filter((f) => f.status === "done").length;
const totalSize = files
.filter((f) => f.status !== "error")
.reduce((sum, f) => sum + f.size, 0);
const itemMotion = reduce
? {}
: {
initial: { opacity: 0, y: 10, scale: 0.98 },
animate: { opacity: 1, y: 0, scale: 1 },
exit: { opacity: 0, x: -14, height: 0, marginTop: 0 },
transition: { duration: 0.22 },
};
return (
<section className="relative w-full overflow-hidden bg-slate-50 py-16 px-4 sm:px-6 lg:py-24 dark:bg-slate-950">
<style>{`
@keyframes fudFloat { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
@keyframes fudShimmer { 0% { transform: translateX(-110%); } 100% { transform: translateX(110%); } }
@keyframes fudRing { 0% { opacity: .55; transform: scale(.97); } 100% { opacity: 0; transform: scale(1.06); } }
.fud-float { animation: fudFloat 3.2s ease-in-out infinite; }
.fud-shimmer { animation: fudShimmer 1.5s ease-in-out infinite; }
.fud-ring { animation: fudRing 1.3s ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.fud-float, .fud-shimmer, .fud-ring { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-600/15"
/>
<div className="relative mx-auto max-w-2xl">
<div className="mb-8 text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-wider text-indigo-600 shadow-sm dark:border-indigo-500/30 dark:bg-slate-900 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Asset intake
</span>
<h2 className="mt-4 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Upload your files
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600 dark:text-slate-400">
Drop images, PDFs, or documents below. Each file starts processing
the moment it lands.
</p>
</div>
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPT}
onChange={onInputChange}
className="sr-only"
aria-hidden="true"
tabIndex={-1}
/>
<button
type="button"
onClick={openPicker}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
aria-label="Upload files. Click to browse, or drag and drop files onto this area."
className={[
"group relative flex w-full flex-col items-center justify-center gap-4 overflow-hidden rounded-2xl border-2 border-dashed px-6 py-12 text-center transition-colors duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950",
isDragging
? "border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-500/10"
: "border-slate-300 bg-white hover:border-indigo-400 hover:bg-indigo-50/40 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-indigo-500 dark:hover:bg-slate-800/60",
].join(" ")}
>
<span className="relative flex h-16 w-16 items-center justify-center">
{isDragging && !reduce ? (
<span className="fud-ring absolute inset-0 rounded-full bg-indigo-400/40 dark:bg-indigo-400/25" />
) : null}
<span
className={[
"relative flex h-16 w-16 items-center justify-center rounded-full transition-colors",
isDragging
? "bg-indigo-100 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-300"
: "bg-slate-100 text-slate-500 group-hover:bg-indigo-100 group-hover:text-indigo-600 dark:bg-slate-800 dark:text-slate-400 dark:group-hover:bg-indigo-500/20 dark:group-hover:text-indigo-300",
].join(" ")}
>
<CloudUpIcon className={`h-8 w-8 ${isDragging && !reduce ? "fud-float" : ""}`} />
</span>
</span>
<span className="flex flex-col gap-1">
<span className="text-base font-semibold text-slate-800 dark:text-slate-100">
{isDragging ? "Release to upload" : "Drag & drop files here"}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
or{" "}
<span className="font-semibold text-indigo-600 underline-offset-2 group-hover:underline dark:text-indigo-400">
browse from your device
</span>
</span>
</span>
<span className="mt-1 text-xs text-slate-400 dark:text-slate-500">
PNG, JPG, GIF, WEBP, SVG, PDF, DOC, TXT · up to 15 MB each
</span>
</button>
<p aria-live="polite" className="sr-only">
{announce}
</p>
{total > 0 ? (
<div className="mt-6">
<div className="mb-3 flex items-center justify-between px-1">
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{total} file{total > 1 ? "s" : ""}
<span className="text-slate-400 dark:text-slate-500">
{" "}
· {formatSize(totalSize)} · {doneCount} ready
</span>
</p>
<button
type="button"
onClick={clearAll}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-semibold text-slate-500 transition-colors hover:bg-rose-50 hover:text-rose-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:text-slate-400 dark:hover:bg-rose-500/10 dark:hover:text-rose-400 dark:focus-visible:ring-offset-slate-950"
>
<TrashIcon className="h-3.5 w-3.5" />
Clear all
</button>
</div>
<ul className="flex flex-col gap-2.5">
<AnimatePresence initial={false}>
{files.map((file) => {
const isError = file.status === "error";
const isDone = file.status === "done";
return (
<motion.li
key={file.id}
layout={!reduce}
{...itemMotion}
className={[
"flex items-center gap-3 overflow-hidden rounded-xl border bg-white p-3 shadow-sm dark:bg-slate-900",
isError
? "border-rose-200 dark:border-rose-500/40"
: "border-slate-200 dark:border-slate-700/70",
].join(" ")}
>
<div
className={[
"flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-lg",
isError
? "bg-rose-50 text-rose-500 dark:bg-rose-500/10 dark:text-rose-400"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
].join(" ")}
>
{file.previewUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={file.previewUrl}
alt={`Preview of ${file.name}`}
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
) : (
<FileGlyph kind={file.kind} className="h-6 w-6" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
{file.name}
</p>
<span className="shrink-0 text-xs tabular-nums text-slate-400 dark:text-slate-500">
{formatSize(file.size)}
</span>
</div>
{isError ? (
<p className="mt-1 flex items-center gap-1.5 text-xs font-medium text-rose-600 dark:text-rose-400">
<AlertIcon className="h-3.5 w-3.5" />
{file.error}
</p>
) : (
<div className="mt-2">
<div
role="progressbar"
aria-valuenow={Math.round(file.progress)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Upload progress for ${file.name}`}
className="relative h-1.5 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"
>
<div
className={[
"h-full rounded-full transition-[width] duration-300 ease-out",
isDone ? "bg-emerald-500" : "bg-indigo-500",
].join(" ")}
style={{ width: `${file.progress}%` }}
>
{!isDone && !reduce ? (
<span className="fud-shimmer absolute inset-y-0 left-0 w-1/2 bg-gradient-to-r from-transparent via-white/50 to-transparent" />
) : null}
</div>
</div>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{isDone
? "Uploaded"
: `Uploading… ${Math.round(file.progress)}%`}
</p>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{isDone ? (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/15 dark:text-emerald-400">
<CheckIcon className="h-3.5 w-3.5" />
</span>
) : null}
<button
type="button"
onClick={() => removeFile(file)}
aria-label={`Remove ${file.name}`}
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
</motion.li>
);
})}
</AnimatePresence>
</ul>
</div>
) : null}
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Login Form
Originalclean login form with social buttons

Signup Form
Originalsignup form with password strength

Contact Form
Originalcontact form with fields and message

Multistep Form
Originalmulti-step wizard form with progress

Payment Form
Originalcard payment form with formatting

Newsletter Form
Originalinline newsletter subscribe form

Search Filters Form
Originalfaceted search filter panel

Profile Form
Originalprofile settings form

OTP Verify Form
OriginalOTP verification form

Feedback Form
Originalfeedback form with emoji rating

Survey Form
Originalshort survey with radios and scale

Booking Form
Originalbooking form with date and slots

