File Manager Upload
Original · freeupload dropzone with progress
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/filemgr-upload.json"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type DragEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Kind = "image" | "pdf" | "sheet" | "video" | "archive" | "doc";
type Status = "uploading" | "conflict" | "done" | "error";
type Resolution = "replaced" | "renamed";
interface QueueItem {
id: string;
name: string;
size: number;
kind: Kind;
progress: number;
status: Status;
note: string | null;
retryable: boolean;
resolution: Resolution | null;
}
interface Folder {
id: string;
label: string;
path: string;
existing: string[];
}
const FOLDERS: Folder[] = [
{
id: "q3-launch",
label: "Q3 Launch",
path: "Workspace / Marketing / Q3 Launch",
existing: ["launch-deck.pdf", "hero-poster.webp", "pricing-v4.xlsx"],
},
{
id: "northwind",
label: "Client · Northwind",
path: "Workspace / Clients / Northwind",
existing: ["logo-primary.svg", "brand-guide.pdf"],
},
{
id: "archive-2025",
label: "Archive 2025",
path: "Workspace / Archive / 2025",
existing: ["q4-report.pdf"],
},
];
const MAX_BYTES = 250_000_000;
const QUOTA_BYTES = 50_000_000_000;
const BASE_USED_BYTES = 19_756_000_000;
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "webp", "gif", "svg", "avif", "tiff", "heic"]);
const SHEET_EXT = new Set(["xlsx", "xls", "csv", "numbers"]);
const VIDEO_EXT = new Set(["mp4", "mov", "webm", "avi", "mkv"]);
const ARCHIVE_EXT = new Set(["zip", "rar", "7z", "tar", "gz"]);
const SEED: QueueItem[] = [
{
id: "fmu-seed-1",
name: "northwind-hero-cut.mp4",
size: 148_900_000,
kind: "video",
progress: 46,
status: "uploading",
note: null,
retryable: true,
resolution: null,
},
{
id: "fmu-seed-2",
name: "launch-deck.pdf",
size: 8_400_000,
kind: "pdf",
progress: 0,
status: "conflict",
note: "A file with this name is already in Q3 Launch",
retryable: true,
resolution: null,
},
{
id: "fmu-seed-3",
name: "field-photos-batch.zip",
size: 62_300_000,
kind: "archive",
progress: 100,
status: "done",
note: null,
retryable: false,
resolution: null,
},
{
id: "fmu-seed-4",
name: "pricing-v4.xlsx",
size: 412_000,
kind: "sheet",
progress: 71,
status: "error",
note: "Transfer interrupted — the connection reset",
retryable: true,
resolution: null,
},
{
id: "fmu-seed-5",
name: "print-master-cmyk.tiff",
size: 268_400_000,
kind: "image",
progress: 0,
status: "error",
note: "Over the 250 MB per-file limit",
retryable: false,
resolution: null,
},
];
function extOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot === -1 ? "" : name.slice(dot + 1).toLowerCase();
}
function kindOf(name: string): Kind {
const ext = extOf(name);
if (IMAGE_EXT.has(ext)) return "image";
if (ext === "pdf") return "pdf";
if (SHEET_EXT.has(ext)) return "sheet";
if (VIDEO_EXT.has(ext)) return "video";
if (ARCHIVE_EXT.has(ext)) return "archive";
return "doc";
}
function formatSize(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1000)));
const val = bytes / Math.pow(1000, i);
return `${val >= 100 || i === 0 ? Math.round(val) : val.toFixed(1)} ${units[i]}`;
}
function uniqueName(name: string, taken: Set<string>): string {
const dot = name.lastIndexOf(".");
const stem = dot === -1 ? name : name.slice(0, dot);
const tail = dot === -1 ? "" : name.slice(dot);
let n = 1;
let next = `${stem} (${n})${tail}`;
while (taken.has(next.toLowerCase())) {
n += 1;
next = `${stem} (${n})${tail}`;
}
return next;
}
const KIND_LABEL: Record<Kind, string> = {
image: "Image",
pdf: "PDF",
sheet: "Spreadsheet",
video: "Video",
archive: "Archive",
doc: "Document",
};
const KIND_TILE: Record<Kind, string> = {
image: "bg-sky-50 text-sky-600 dark:bg-sky-500/10 dark:text-sky-300",
pdf: "bg-rose-50 text-rose-600 dark:bg-rose-500/10 dark:text-rose-300",
sheet: "bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-300",
video: "bg-violet-50 text-violet-600 dark:bg-violet-500/10 dark:text-violet-300",
archive: "bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-300",
doc: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400",
};
function FolderIcon({ 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="M3 7.5A1.5 1.5 0 0 1 4.5 6h4.1a1.5 1.5 0 0 1 1.2.6l1 1.4h8.7A1.5 1.5 0 0 1 21 9.5v8A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5z" />
</svg>
);
}
function TrayIcon({ 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="M12 16V4" />
<path d="m7.5 8.5 4.5-4.5 4.5 4.5" />
<path d="M4 14v3.5A2.5 2.5 0 0 0 6.5 20h11a2.5 2.5 0 0 0 2.5-2.5V14" />
</svg>
);
}
function ImageGlyph({ 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"
>
<rect x="3" y="4.5" width="18" height="15" rx="2.5" />
<circle cx="8.5" cy="10" r="1.5" />
<path d="m3.5 16.5 4.6-4.2a2 2 0 0 1 2.7 0l6.4 5.9" />
</svg>
);
}
function PageGlyph({ 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="M13 3H7.5A2 2 0 0 0 5.5 5v14a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2V8.5z" />
<path d="M13 3v5.5h5.5" />
<path d="M9 13h6M9 16.5h4" />
</svg>
);
}
function SheetGlyph({ 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"
>
<rect x="4" y="4" width="16" height="16" rx="2.5" />
<path d="M4 9.5h16M4 14.5h16M10.5 9.5V20" />
</svg>
);
}
function VideoGlyph({ 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"
>
<rect x="3" y="5.5" width="13" height="13" rx="2.5" />
<path d="m16 11 5-2.8v7.6L16 13z" />
</svg>
);
}
function ArchiveGlyph({ 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"
>
<rect x="3.5" y="4" width="17" height="4.5" rx="1.5" />
<path d="M5 8.5v9.5A2 2 0 0 0 7 20h10a2 2 0 0 0 2-2V8.5" />
<path d="M11 12h2M11 15h2" />
</svg>
);
}
function KindGlyph({ kind, className }: { kind: Kind; className?: string }) {
if (kind === "image") return <ImageGlyph className={className} />;
if (kind === "sheet") return <SheetGlyph className={className} />;
if (kind === "video") return <VideoGlyph className={className} />;
if (kind === "archive") return <ArchiveGlyph className={className} />;
return <PageGlyph className={className} />;
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
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 8.5v4.5M12 16.5h.01" />
<circle cx="12" cy="12" r="8.75" />
</svg>
);
}
function RetryIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.9}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 5.5v5h-5" />
<path d="M19.4 14a7.5 7.5 0 1 1-1.3-6.9L20 10.5" />
</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.5 6.5l11 11M17.5 6.5l-11 11" />
</svg>
);
}
export default function FileManagerUpload() {
const reduce = useReducedMotion();
const inputRef = useRef<HTMLInputElement>(null);
const timers = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map());
const dragDepth = useRef(0);
const seq = useRef(0);
const [items, setItems] = useState<QueueItem[]>(SEED);
const [destId, setDestId] = useState<string>(FOLDERS[0].id);
const [isDragging, setIsDragging] = useState(false);
const [announce, setAnnounce] = useState("");
const dest = FOLDERS.find((f) => f.id === destId) ?? FOLDERS[0];
const startTransfer = useCallback((id: string) => {
const existing = timers.current.get(id);
if (existing !== undefined) clearInterval(existing);
const timer = setInterval(() => {
setItems((prev) =>
prev.map((item) => {
if (item.id !== id || item.status !== "uploading") return item;
const next = Math.min(100, item.progress + Math.random() * 9 + 3);
if (next >= 100) {
const t = timers.current.get(id);
if (t !== undefined) {
clearInterval(t);
timers.current.delete(id);
}
return { ...item, progress: 100, status: "done", note: item.note };
}
return { ...item, progress: next };
}),
);
}, 420);
timers.current.set(id, timer);
}, []);
useEffect(() => {
SEED.forEach((item) => {
if (item.status === "uploading") startTransfer(item.id);
});
const active = timers.current;
return () => {
active.forEach((t) => clearInterval(t));
active.clear();
};
}, [startTransfer]);
const stopTransfer = useCallback((id: string) => {
const t = timers.current.get(id);
if (t !== undefined) {
clearInterval(t);
timers.current.delete(id);
}
}, []);
const addFiles = useCallback(
(list: FileList) => {
const incoming = Array.from(list);
if (incoming.length === 0) return;
const taken = new Set<string>([
...dest.existing.map((n) => n.toLowerCase()),
...items.map((i) => i.name.toLowerCase()),
]);
const created: QueueItem[] = incoming.map((file) => {
const id = `fmu-${Date.now().toString(36)}-${seq.current++}`;
const clash = taken.has(file.name.toLowerCase());
taken.add(file.name.toLowerCase());
if (file.size > MAX_BYTES) {
return {
id,
name: file.name,
size: file.size,
kind: kindOf(file.name),
progress: 0,
status: "error",
note: "Over the 250 MB per-file limit",
retryable: false,
resolution: null,
};
}
return {
id,
name: file.name,
size: file.size,
kind: kindOf(file.name),
progress: 0,
status: clash ? "conflict" : "uploading",
note: clash ? `A file with this name is already in ${dest.label}` : null,
retryable: true,
resolution: null,
};
});
setItems((prev) => [...created, ...prev]);
created.forEach((item) => {
if (item.status === "uploading") startTransfer(item.id);
});
const clashes = created.filter((i) => i.status === "conflict").length;
setAnnounce(
`${created.length} file${created.length === 1 ? "" : "s"} queued for ${dest.label}` +
(clashes > 0 ? `. ${clashes} need a name conflict resolved.` : ""),
);
},
[dest, items, startTransfer],
);
const resolveConflict = useCallback(
(id: string, choice: Resolution) => {
const target = items.find((i) => i.id === id);
if (!target) return;
const taken = new Set<string>([
...dest.existing.map((n) => n.toLowerCase()),
...items.filter((i) => i.id !== id).map((i) => i.name.toLowerCase()),
]);
const name = choice === "renamed" ? uniqueName(target.name, taken) : target.name;
setItems((prev) =>
prev.map((item) =>
item.id === id
? {
...item,
name,
status: "uploading",
progress: 0,
resolution: choice,
note:
choice === "renamed"
? "Saved alongside the original"
: "Overwrites the existing copy",
}
: item,
),
);
startTransfer(id);
setAnnounce(
choice === "renamed"
? `Keeping both. Uploading as ${name}.`
: `Replacing the existing ${target.name} in ${dest.label}.`,
);
},
[dest, items, startTransfer],
);
const retry = useCallback(
(id: string) => {
setItems((prev) =>
prev.map((item) =>
item.id === id
? { ...item, status: "uploading", progress: 0, note: "Retrying transfer" }
: item,
),
);
startTransfer(id);
setAnnounce("Retrying transfer");
},
[startTransfer],
);
const remove = useCallback(
(item: QueueItem) => {
stopTransfer(item.id);
setItems((prev) => prev.filter((i) => i.id !== item.id));
setAnnounce(`${item.name} removed from the queue`);
},
[stopTransfer],
);
const clearFinished = useCallback(() => {
setItems((prev) => prev.filter((i) => i.status !== "done"));
setAnnounce("Finished uploads cleared from the list");
}, []);
const onDestChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const folder = FOLDERS.find((f) => f.id === e.target.value);
if (!folder) return;
setDestId(folder.id);
setAnnounce(`Destination set to ${folder.path}`);
}, []);
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 transferable = items.filter((i) => i.status !== "error");
const totalBytes = transferable.reduce((sum, i) => sum + i.size, 0);
const movedBytes = transferable.reduce((sum, i) => sum + (i.size * i.progress) / 100, 0);
const overall = totalBytes === 0 ? 0 : Math.round((movedBytes / totalBytes) * 100);
const inFlight = items.filter((i) => i.status === "uploading").length;
const conflicts = items.filter((i) => i.status === "conflict").length;
const finished = items.filter((i) => i.status === "done").length;
const usedBytes =
BASE_USED_BYTES + items.filter((i) => i.status === "done").reduce((s, i) => s + i.size, 0);
const usedPct = Math.min(100, (usedBytes / QUOTA_BYTES) * 100);
const RING_R = 20;
const CIRC = 2 * Math.PI * RING_R;
const rowMotion = reduce
? {}
: {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, x: -12, height: 0, marginBottom: 0 },
transition: { duration: 0.2 },
};
return (
<section className="relative w-full bg-slate-100 px-4 py-16 sm:px-6 lg:py-24 dark:bg-slate-950">
<style>{`
@keyframes fmuStripe { from { background-position: 0 0; } to { background-position: 28px 0; } }
@keyframes fmuPulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .4; transform: scale(.72); } }
@keyframes fmuLift { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
.fmu-stripe {
background-image: repeating-linear-gradient(115deg, rgba(255,255,255,.30) 0 7px, transparent 7px 14px);
background-size: 28px 100%;
animation: fmuStripe .7s linear infinite;
}
.fmu-pulse { animation: fmuPulse 1.6s ease-in-out infinite; }
.fmu-lift { animation: fmuLift 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.fmu-stripe, .fmu-pulse, .fmu-lift { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-3xl">
<header className="mb-6">
<h2 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Move files into your workspace
</h2>
<p className="mt-2 max-w-xl text-sm text-slate-600 dark:text-slate-400">
Pick a destination folder, then drop files in. Anything that collides with a
filename already in that folder waits for you to decide.
</p>
</header>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-col gap-5 border-b border-slate-200 p-5 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
<div className="min-w-0">
<div className="flex items-center gap-2 text-slate-400 dark:text-slate-500">
<FolderIcon className="h-4 w-4 shrink-0" />
<p className="truncate text-xs font-medium tracking-wide">{dest.path}</p>
</div>
<p className="mt-1.5 text-lg font-semibold text-slate-900 dark:text-white">
{inFlight > 0
? `Uploading ${inFlight} file${inFlight === 1 ? "" : "s"}`
: conflicts > 0
? `${conflicts} name conflict${conflicts === 1 ? "" : "s"} to resolve`
: "Transfer queue idle"}
</p>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
{formatSize(movedBytes)} of {formatSize(totalBytes)} moved · {finished} complete
</p>
</div>
<div className="flex shrink-0 items-center gap-4">
<div className="relative h-14 w-14">
<svg viewBox="0 0 48 48" className="h-14 w-14 -rotate-90" aria-hidden="true">
<circle
cx="24"
cy="24"
r={RING_R}
fill="none"
strokeWidth="4"
className="stroke-slate-200 dark:stroke-slate-800"
/>
<circle
cx="24"
cy="24"
r={RING_R}
fill="none"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray={CIRC}
strokeDashoffset={CIRC - (CIRC * overall) / 100}
className="stroke-violet-500 transition-[stroke-dashoffset] duration-500 ease-out motion-reduce:transition-none dark:stroke-violet-400"
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-xs font-bold tabular-nums text-slate-700 dark:text-slate-200">
{overall}%
</span>
</div>
<p className="sr-only" aria-live="polite">
Overall transfer progress {overall} percent
</p>
<button
type="button"
onClick={clearFinished}
disabled={finished === 0}
className="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition-colors hover:bg-slate-50 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
>
Clear finished
</button>
</div>
</div>
<div className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
<fieldset>
<legend className="mb-2.5 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Destination folder
</legend>
<div className="flex flex-wrap gap-2">
{FOLDERS.map((folder) => (
<label key={folder.id} className="cursor-pointer">
<input
type="radio"
name="fmu-destination"
value={folder.id}
checked={destId === folder.id}
onChange={onDestChange}
className="peer sr-only"
/>
<span className="flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors peer-hover:border-slate-300 peer-hover:bg-slate-50 peer-checked:border-violet-500 peer-checked:bg-violet-50 peer-checked:text-violet-700 peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:peer-hover:border-slate-600 dark:peer-hover:bg-slate-800 dark:peer-checked:border-violet-400 dark:peer-checked:bg-violet-500/15 dark:peer-checked:text-violet-200 dark:peer-focus-visible:ring-offset-slate-900">
<FolderIcon className="h-4 w-4" />
{folder.label}
</span>
</label>
))}
</div>
</fieldset>
</div>
<div className="p-5">
<input
ref={inputRef}
type="file"
multiple
onChange={onInputChange}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
aria-label={`Add files to ${dest.path}. Activate to browse, or drag files onto this area.`}
className={[
"group flex w-full items-center gap-4 rounded-xl border-2 border-dashed px-5 py-6 text-left transition-colors duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
isDragging
? "border-violet-500 bg-violet-50 dark:border-violet-400 dark:bg-violet-500/10"
: "border-slate-300 hover:border-violet-400 hover:bg-violet-50/50 dark:border-slate-700 dark:hover:border-violet-500 dark:hover:bg-slate-800/60",
].join(" ")}
>
<span
className={[
"flex h-11 w-11 shrink-0 items-center justify-center rounded-lg transition-colors",
isDragging
? "bg-violet-100 text-violet-600 dark:bg-violet-500/20 dark:text-violet-300"
: "bg-slate-100 text-slate-500 group-hover:bg-violet-100 group-hover:text-violet-600 dark:bg-slate-800 dark:text-slate-400 dark:group-hover:bg-violet-500/20 dark:group-hover:text-violet-300",
].join(" ")}
>
<TrayIcon className={`h-5 w-5 ${isDragging && !reduce ? "fmu-lift" : ""}`} />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-slate-800 dark:text-slate-100">
{isDragging ? `Release to drop into ${dest.label}` : "Drop files here, or browse"}
</span>
<span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">
Any file type · 250 MB per file · {formatSize(QUOTA_BYTES - usedBytes)} left in
your plan
</span>
</span>
</button>
<p aria-live="polite" className="sr-only">
{announce}
</p>
<ul className="mt-4 space-y-2.5">
<AnimatePresence initial={false}>
{items.map((item) => {
const isConflict = item.status === "conflict";
const isError = item.status === "error";
const isDone = item.status === "done";
const pct = Math.round(item.progress);
return (
<motion.li
key={item.id}
layout={!reduce}
{...rowMotion}
className={[
"overflow-hidden rounded-xl border p-3",
isConflict
? "border-amber-300 bg-amber-50/70 dark:border-amber-500/40 dark:bg-amber-500/5"
: isError
? "border-rose-200 bg-rose-50/50 dark:border-rose-500/40 dark:bg-rose-500/5"
: "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900",
].join(" ")}
>
<div className="flex items-start gap-3">
<span
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${KIND_TILE[item.kind]}`}
>
<KindGlyph kind={item.kind} className="h-5 w-5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<p className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
{item.name}
</p>
<span className="shrink-0 text-xs tabular-nums text-slate-400 dark:text-slate-500">
{formatSize(item.size)}
</span>
</div>
<p className="mt-0.5 text-xs text-slate-400 dark:text-slate-500">
{KIND_LABEL[item.kind]}
{item.resolution === "renamed" ? " · kept both" : null}
{item.resolution === "replaced" ? " · replacing original" : null}
</p>
{isConflict ? (
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<span className="flex items-center gap-1.5 text-xs font-medium text-amber-700 dark:text-amber-300">
<span className="fmu-pulse h-1.5 w-1.5 rounded-full bg-amber-500" />
{item.note}
</span>
<span className="flex gap-2">
<button
type="button"
onClick={() => resolveConflict(item.id, "renamed")}
aria-label={`Keep both copies of ${item.name}`}
className="rounded-md bg-amber-500 px-2.5 py-1 text-xs font-semibold text-white transition-colors hover:bg-amber-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
>
Keep both
</button>
<button
type="button"
onClick={() => resolveConflict(item.id, "replaced")}
aria-label={`Replace the existing ${item.name} in ${dest.label}`}
className="rounded-md border border-amber-300 px-2.5 py-1 text-xs font-semibold text-amber-700 transition-colors hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-amber-500/50 dark:text-amber-300 dark:hover:bg-amber-500/15 dark:focus-visible:ring-offset-slate-900"
>
Replace
</button>
</span>
</div>
) : isError ? (
<p className="mt-1.5 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 shrink-0" />
{item.note}
</p>
) : (
<div className="mt-2">
<div
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={`${pct} percent, ${formatSize((item.size * item.progress) / 100)} of ${formatSize(item.size)}`}
aria-label={`Upload progress for ${item.name}`}
className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"
>
<div
className={[
"relative h-full rounded-full transition-[width] duration-500 ease-out motion-reduce:transition-none",
isDone ? "bg-emerald-500" : "bg-violet-500",
].join(" ")}
style={{ width: `${item.progress}%` }}
>
{!isDone ? (
<span className="fmu-stripe absolute inset-0 rounded-full" />
) : null}
</div>
</div>
<p className="mt-1 text-xs tabular-nums text-slate-500 dark:text-slate-400">
{isDone
? item.resolution === "replaced"
? `Replaced in ${dest.label}`
: `Saved to ${dest.label}`
: `${formatSize((item.size * item.progress) / 100)} of ${formatSize(item.size)} · ${pct}%`}
</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"
title="Upload complete"
>
<CheckIcon className="h-3.5 w-3.5" />
</span>
) : null}
{isError && item.retryable ? (
<button
type="button"
onClick={() => retry(item.id)}
aria-label={`Retry uploading ${item.name}`}
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-violet-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:hover:bg-slate-800 dark:hover:text-violet-300 dark:focus-visible:ring-offset-slate-900"
>
<RetryIcon className="h-4 w-4" />
</button>
) : null}
<button
type="button"
onClick={() => remove(item)}
aria-label={
item.status === "uploading"
? `Cancel upload of ${item.name}`
: `Remove ${item.name} from the queue`
}
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 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-white dark:hover:bg-slate-800 dark:hover:text-rose-400 dark:focus-visible:ring-offset-slate-900"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
</div>
</motion.li>
);
})}
</AnimatePresence>
</ul>
{items.length === 0 ? (
<p className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
The transfer queue is empty. Drop files above to start.
</p>
) : null}
</div>
<div className="border-t border-slate-200 px-5 py-4 dark:border-slate-800">
<div className="flex items-baseline justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
Workspace storage
</p>
<p className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
{formatSize(usedBytes)} of {formatSize(QUOTA_BYTES)}
</p>
</div>
<div
role="progressbar"
aria-valuenow={Math.round(usedPct)}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={`${formatSize(usedBytes)} of ${formatSize(QUOTA_BYTES)} used`}
aria-label="Workspace storage used"
className="mt-2 h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"
>
<div
className="h-full rounded-full bg-sky-500 transition-[width] duration-700 ease-out motion-reduce:transition-none dark:bg-sky-400"
style={{ width: `${usedPct}%` }}
/>
</div>
</div>
</div>
</div>
</section>
);
}Dependencies
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 →
File Manager List
Originalfile manager list view

File Manager Grid
Originalfile manager grid view

File Manager Breadcrumb
Originalfile path breadcrumb with toolbar

File Manager Tree
Originalfolder tree sidebar

File Manager Preview
Originalfile preview panel

File Manager Storage
Originalstorage usage widget

File Manager Recent
Originalrecent files list

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

