A drag-and-drop upload target that lifts on drag-over, renders dropped files as rows with animated progress and a success check, and shakes on rejection.
npx shadcn@latest add @paragon/file-dropzone"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, FileText, UploadCloud, X } from "lucide-react";
import { cn } from "@/lib/utils";
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];
interface DropzoneFile {
id: string;
name: string;
size: number;
/** 0–1 upload progress. */
progress: number;
done: boolean;
}
export interface FileDropzoneProps
extends Omit<React.ComponentProps<"div">, "onDrop"> {
/** Comma-separated accept list, same syntax as `<input accept>`. */
accept?: string;
/** Maximum file size in bytes. */
maxSize?: number;
multiple?: boolean;
onFilesAccepted?: (files: File[]) => void;
onFileRejected?: (file: File, reason: "type" | "size") => void;
/**
* Drive a real upload; call `onProgress` with 0–1 and resolve when done.
* When omitted, progress is simulated so the zero-props render still works.
*/
onUpload?: (file: File, onProgress: (fraction: number) => void) => Promise<void>;
}
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function matchesAccept(file: File, accept: string) {
return accept.split(",").some((raw) => {
const rule = raw.trim().toLowerCase();
if (!rule) return false;
if (rule.startsWith(".")) return file.name.toLowerCase().endsWith(rule);
if (rule.endsWith("/*")) return file.type.startsWith(rule.slice(0, -1));
return file.type === rule;
});
}
/**
* A drag-and-drop upload target. Drag-over lifts the border and gently
* scales the content; dropped files render as rows with an animated
* progress bar and a success check. Rejected files shake the zone.
*/
export function FileDropzone({
accept,
maxSize,
multiple = true,
onFilesAccepted,
onFileRejected,
onUpload,
className,
...props
}: FileDropzoneProps) {
const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const reduced = useReducedMotion();
const [files, setFiles] = React.useState<DropzoneFile[]>([]);
const [dragging, setDragging] = React.useState(false);
const [shaking, setShaking] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const dragDepth = React.useRef(0);
const timers = React.useRef<Map<string, ReturnType<typeof setInterval>>>(
new Map(),
);
const counter = React.useRef(0);
React.useEffect(() => {
const active = timers.current;
return () => {
for (const timer of active.values()) clearInterval(timer);
};
}, []);
const setProgress = (id: string, fraction: number) => {
setFiles((prev) =>
prev.map((f) =>
f.id === id
? { ...f, progress: Math.min(1, fraction), done: fraction >= 1 }
: f,
),
);
};
const beginUpload = (file: File, id: string) => {
if (onUpload) {
void onUpload(file, (fraction) => setProgress(id, fraction)).then(() =>
setProgress(id, 1),
);
return;
}
// Simulated upload: ease toward completion, then finish.
let fraction = 0;
const timer = setInterval(() => {
fraction += (1.05 - fraction) * 0.16;
if (fraction >= 0.995) {
clearInterval(timer);
timers.current.delete(id);
setProgress(id, 1);
} else {
setProgress(id, fraction);
}
}, 90);
timers.current.set(id, timer);
};
const handleFiles = (list: FileList | null) => {
if (!list || list.length === 0) return;
const incoming = multiple ? Array.from(list) : [list[0]];
const accepted: File[] = [];
let rejected = false;
for (const file of incoming) {
if (accept && !matchesAccept(file, accept)) {
rejected = true;
onFileRejected?.(file, "type");
continue;
}
if (maxSize !== undefined && file.size > maxSize) {
rejected = true;
onFileRejected?.(file, "size");
continue;
}
accepted.push(file);
}
if (rejected) setShaking(true);
if (accepted.length > 0) {
const rows = accepted.map((file) => ({
id: `${uid}-${counter.current++}`,
name: file.name,
size: file.size,
progress: 0,
done: false,
}));
setFiles((prev) => [...prev, ...rows]);
accepted.forEach((file, i) => beginUpload(file, rows[i].id));
onFilesAccepted?.(accepted);
}
};
const removeFile = (id: string) => {
const timer = timers.current.get(id);
if (timer) {
clearInterval(timer);
timers.current.delete(id);
}
setFiles((prev) => prev.filter((f) => f.id !== id));
};
return (
<div className={cn("flex w-full flex-col gap-3", className)} {...props}>
<style href="paragon-file-dropzone" precedence="paragon">{`
@keyframes pg-file-dropzone-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(2px); }
}
@media (prefers-reduced-motion: reduce) {
@keyframes pg-file-dropzone-shake {
0%, 100% { transform: none; }
}
}
`}</style>
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragEnter={(e) => {
e.preventDefault();
dragDepth.current += 1;
setDragging(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragging(false);
}}
onDrop={(e) => {
e.preventDefault();
dragDepth.current = 0;
setDragging(false);
handleFiles(e.dataTransfer.files);
}}
onAnimationEnd={() => setShaking(false)}
style={
shaking
? { animation: `pg-file-dropzone-shake 300ms var(--ease-in-out)` }
: undefined
}
className={cn(
"flex w-full flex-col items-center justify-center gap-1 rounded-xl bg-card px-6 py-8 text-center transition-[box-shadow,background-color] duration-150 ease-out",
dragging ? "shadow-border-hover bg-accent/40" : "shadow-border",
)}
>
<div
className={cn(
"flex flex-col items-center gap-1 transition-[scale] duration-150 ease-out",
dragging && "scale-[1.01]",
)}
>
<UploadCloud
className="mb-1 size-5 text-muted-foreground"
aria-hidden
/>
<span className="text-sm font-medium">
{dragging ? "Drop to upload" : "Drag files here or click to browse"}
</span>
<span className="text-xs text-muted-foreground">
{accept ? `${accept.replaceAll(",", ", ")}` : "Any file type"}
{maxSize !== undefined && ` · up to ${formatBytes(maxSize)}`}
</span>
</div>
</button>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
className="sr-only"
tabIndex={-1}
aria-hidden
onChange={(e) => {
handleFiles(e.target.files);
e.target.value = "";
}}
/>
<AnimatePresence initial={false}>
{files.map((file) => (
<motion.div
key={file.id}
layout={!reduced}
initial={
reduced
? { opacity: 0 }
: { opacity: 0, y: 12, filter: "blur(4px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={
reduced
? { opacity: 0, transition: { duration: 0.15 } }
: {
opacity: 0,
y: -8,
filter: "blur(4px)",
transition: { duration: 0.15, ease: EASE_EXIT },
}
}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className="flex items-center gap-3 rounded-lg bg-card px-3 py-2.5 shadow-border"
>
<FileText
className="size-4 shrink-0 text-muted-foreground"
aria-hidden
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium">
{file.name}
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{file.done
? formatBytes(file.size)
: `${Math.round(file.progress * 100)}%`}
</span>
</div>
<div
role="progressbar"
aria-valuenow={Math.round(file.progress * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Uploading ${file.name}`}
className="mt-1.5 h-1 overflow-hidden rounded-full bg-secondary"
>
<div
className={cn(
"h-full w-full origin-left rounded-full bg-primary transition-transform duration-150 ease-linear",
file.done && "bg-emerald-500",
)}
style={{ transform: `scaleX(${file.progress})` }}
/>
</div>
</div>
<span className="relative flex size-5 shrink-0 items-center justify-center">
<AnimatePresence mode="popLayout" initial={false}>
{file.done ? (
<motion.span
key="done"
initial={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="flex size-4 items-center justify-center rounded-full bg-emerald-500 text-white"
>
<Check className="size-3" aria-hidden />
</motion.span>
) : null}
</AnimatePresence>
</span>
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => removeFile(file.id)}
className="relative flex size-5 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-100 after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-x-1/2 after:-translate-y-1/2 hover:text-foreground"
>
<X className="size-3.5" aria-hidden />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
);
}