Empty Folder
Original · freeempty folder with upload CTA
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/empty-folder.json"use client";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { useCallback, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type QueuedFile = {
id: string;
name: string;
size: number;
kind: "doc" | "sheet" | "image" | "archive" | "other";
};
const ACCEPTED_LABEL = "PDF, PNG, XLSX, ZIP up to 25 MB";
const MAX_BYTES = 25 * 1024 * 1024;
function classifyName(name: string): QueuedFile["kind"] {
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
if (["pdf", "doc", "docx", "txt", "md", "rtf"].includes(ext)) return "doc";
if (["xls", "xlsx", "csv", "numbers"].includes(ext)) return "sheet";
if (["png", "jpg", "jpeg", "webp", "gif", "svg", "heic"].includes(ext)) return "image";
if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) return "archive";
return "other";
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function FolderMark() {
return (
<svg viewBox="0 0 96 76" aria-hidden="true" className="h-full w-full">
<path
d="M4 16a8 8 0 0 1 8-8h24l9 10h39a8 8 0 0 1 8 8v42a8 8 0 0 1-8 8H12a8 8 0 0 1-8-8V16Z"
className="fill-slate-200/80 dark:fill-slate-800"
/>
<path
d="M4 30h88v34a8 8 0 0 1-8 8H12a8 8 0 0 1-8-8V30Z"
className="fill-white dark:fill-slate-700/70"
/>
<path
d="M4 30h88"
className="stroke-slate-300 dark:stroke-slate-600"
strokeWidth="1.5"
fill="none"
/>
</svg>
);
}
function KindIcon({ kind }: { kind: QueuedFile["kind"] }) {
const common = "h-4 w-4";
if (kind === "sheet") {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={common}>
<rect x="3.5" y="3.5" width="17" height="17" rx="2.5" stroke="currentColor" strokeWidth="1.6" />
<path d="M3.5 9.5h17M3.5 15h17M9.5 3.5v17" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
if (kind === "image") {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={common}>
<rect x="3.5" y="4.5" width="17" height="15" rx="2.5" stroke="currentColor" strokeWidth="1.6" />
<circle cx="9" cy="10" r="1.75" stroke="currentColor" strokeWidth="1.6" />
<path d="M4.5 17.5 9.8 13a1.5 1.5 0 0 1 2 0l6.7 5.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
}
if (kind === "archive") {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={common}>
<rect x="3.5" y="3.5" width="17" height="17" rx="2.5" stroke="currentColor" strokeWidth="1.6" />
<path d="M12 3.5v3M12 8.5v3M12 13.5v2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<rect x="10" y="15.5" width="4" height="4" rx="1" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={common}>
<path
d="M13.5 3.5H7a2.5 2.5 0 0 0-2.5 2.5v12A2.5 2.5 0 0 0 7 20.5h10a2.5 2.5 0 0 0 2.5-2.5V9.5l-6-6Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M13.5 3.5v6h6" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
);
}
export default function EmptyFolder() {
const reduceMotion = useReducedMotion();
const inputRef = useRef<HTMLInputElement>(null);
const dropRef = useRef<HTMLDivElement>(null);
const dragDepth = useRef(0);
const [files, setFiles] = useState<QueuedFile[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
const descId = useId();
const statusId = useId();
const totalBytes = useMemo(
() => files.reduce((sum, file) => sum + file.size, 0),
[files]
);
const addFiles = useCallback((incoming: FileList | null) => {
if (!incoming || incoming.length === 0) return;
const accepted: QueuedFile[] = [];
const rejected: string[] = [];
Array.from(incoming).forEach((file, index) => {
if (file.size > MAX_BYTES) {
rejected.push(file.name);
return;
}
accepted.push({
id: `${Date.now()}-${index}-${file.name}`,
name: file.name,
size: file.size,
kind: classifyName(file.name),
});
});
if (accepted.length > 0) {
setFiles((prev) => [...prev, ...accepted]);
setStatus(
`${accepted.length} file${accepted.length === 1 ? "" : "s"} added to Q3 Client Handover.`
);
}
setError(
rejected.length > 0
? `${rejected.join(", ")} exceeded the 25 MB limit and was skipped.`
: ""
);
}, []);
const removeFile = useCallback((id: string, name: string) => {
setFiles((prev) => prev.filter((file) => file.id !== id));
setStatus(`${name} removed.`);
}, []);
const openPicker = useCallback(() => {
inputRef.current?.click();
}, []);
const onDropZoneKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openPicker();
}
},
[openPicker]
);
return (
<section className="relative w-full bg-white px-5 py-20 sm:px-8 sm:py-28 dark:bg-slate-950">
<style>{`
@keyframes ef-folder-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
@keyframes ef-sheet-rise {
0%, 100% { transform: translateY(0) rotate(var(--ef-rot, 0deg)); }
50% { transform: translateY(-9px) rotate(var(--ef-rot, 0deg)); }
}
@keyframes ef-halo-pulse {
0%, 100% { opacity: 0.35; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.06); }
}
.ef-folder { animation: ef-folder-float 6s ease-in-out infinite; }
.ef-sheet { animation: ef-sheet-rise 5s ease-in-out infinite; }
.ef-sheet-b { animation-delay: -1.6s; }
.ef-sheet-c { animation-delay: -3.2s; }
.ef-halo { animation: ef-halo-pulse 7s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.ef-folder, .ef-sheet, .ef-halo { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-3xl">
<div className="mb-6 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-slate-500 dark:text-slate-400">
<span>Workspace</span>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
<path d="m9 6 6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>Deliverables</span>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
<path d="m9 6 6 6-6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="font-medium text-slate-900 dark:text-slate-100">Q3 Client Handover</span>
</div>
<div
ref={dropRef}
role="button"
tabIndex={0}
aria-labelledby={`${descId}-title`}
aria-describedby={descId}
onKeyDown={onDropZoneKeyDown}
onClick={openPicker}
onDragEnter={(event) => {
event.preventDefault();
dragDepth.current += 1;
setIsDragging(true);
}}
onDragOver={(event) => {
event.preventDefault();
}}
onDragLeave={(event) => {
event.preventDefault();
dragDepth.current -= 1;
if (dragDepth.current <= 0) {
dragDepth.current = 0;
setIsDragging(false);
}
}}
onDrop={(event) => {
event.preventDefault();
dragDepth.current = 0;
setIsDragging(false);
addFiles(event.dataTransfer.files);
}}
className={`group relative cursor-pointer overflow-hidden rounded-3xl border-2 border-dashed px-6 py-14 text-center transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:px-12 sm:py-16 dark:focus-visible:ring-offset-slate-950 ${
isDragging
? "border-indigo-500 bg-indigo-50/70 dark:border-indigo-400 dark:bg-indigo-500/10"
: "border-slate-300 bg-slate-50/60 hover:border-indigo-400 hover:bg-indigo-50/40 dark:border-slate-700 dark:bg-slate-900/40 dark:hover:border-indigo-500/70 dark:hover:bg-indigo-500/5"
}`}
>
<div
aria-hidden="true"
className="ef-halo pointer-events-none absolute left-1/2 top-16 h-56 w-56 -translate-x-1/2 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/20"
/>
<div aria-hidden="true" className="relative mx-auto mb-8 h-32 w-40">
<div className="ef-sheet absolute left-7 top-0 h-16 w-12 rounded-md border border-slate-200 bg-white shadow-sm [--ef-rot:-8deg] dark:border-slate-700 dark:bg-slate-800" style={{ transform: "rotate(-8deg)" }} />
<div className="ef-sheet ef-sheet-b absolute left-1/2 top-[-6px] h-16 w-12 -translate-x-1/2 rounded-md border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-800" />
<div className="ef-sheet ef-sheet-c absolute right-7 top-0 h-16 w-12 rounded-md border border-slate-200 bg-white shadow-sm [--ef-rot:8deg] dark:border-slate-700 dark:bg-slate-800" style={{ transform: "rotate(8deg)" }} />
<div className="ef-folder absolute bottom-0 left-1/2 h-20 w-24 -translate-x-1/2 drop-shadow-sm">
<FolderMark />
</div>
</div>
<h2
id={`${descId}-title`}
className="text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl dark:text-slate-50"
>
{isDragging ? "Drop to upload here" : "This folder is empty"}
</h2>
<p id={descId} className="mx-auto mt-2 max-w-md text-sm leading-6 text-slate-600 dark:text-slate-400">
Drag files in, or browse your device. {ACCEPTED_LABEL}. Everything you add stays private to your workspace until you share it.
</p>
<div className="mt-7 flex flex-col items-center justify-center gap-3 sm:flex-row">
<motion.button
type="button"
onClick={(event) => {
event.stopPropagation();
openPicker();
}}
whileTap={reduceMotion ? undefined : { scale: 0.97 }}
className="inline-flex items-center gap-2 rounded-full bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="M12 16.5V4.5m0 0L7.5 9M12 4.5 16.5 9" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
<path d="M4.5 15v3a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
Upload files
</motion.button>
<a
href="https://webinnoventix.com/docs/uploads"
target="_blank"
rel="noopener"
onClick={(event) => event.stopPropagation()}
className="inline-flex items-center gap-1.5 rounded-full px-4 py-2.5 text-sm font-medium text-slate-700 underline-offset-4 transition-colors hover:text-indigo-700 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-slate-950"
>
How uploads work
</a>
</div>
<input
ref={inputRef}
type="file"
multiple
accept=".pdf,.png,.jpg,.jpeg,.webp,.xlsx,.xls,.csv,.zip,.doc,.docx"
className="sr-only"
aria-label="Choose files to upload to Q3 Client Handover"
onClick={(event) => event.stopPropagation()}
onChange={(event) => {
addFiles(event.target.files);
event.target.value = "";
}}
/>
</div>
<p aria-live="polite" id={statusId} className="sr-only">
{status}
</p>
<AnimatePresence initial={false}>
{error !== "" && (
<motion.p
key="ef-error"
role="alert"
initial={reduceMotion ? false : { opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}
className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300"
>
{error}
</motion.p>
)}
</AnimatePresence>
<AnimatePresence initial={false}>
{files.length > 0 && (
<motion.div
key="ef-queue"
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}
className="mt-8"
>
<div className="mb-3 flex items-baseline justify-between gap-4">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Ready to upload
</h3>
<span className="text-xs text-slate-500 tabular-nums dark:text-slate-400">
{files.length} file{files.length === 1 ? "" : "s"} · {formatBytes(totalBytes)}
</span>
</div>
<ul className="divide-y divide-slate-200 overflow-hidden rounded-2xl border border-slate-200 bg-white dark:divide-slate-800 dark:border-slate-800 dark:bg-slate-900/50">
<AnimatePresence initial={false}>
{files.map((file) => (
<motion.li
key={file.id}
layout={!reduceMotion}
initial={reduceMotion ? false : { opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, x: 8 }}
className="flex items-center gap-3 px-4 py-3"
>
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-indigo-50 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-300">
<KindIcon kind={file.kind} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-slate-900 dark:text-slate-100">
{file.name}
</span>
<span className="block text-xs text-slate-500 tabular-nums dark:text-slate-400">
{formatBytes(file.size)}
</span>
</span>
<button
type="button"
onClick={() => removeFile(file.id, file.name)}
aria-label={`Remove ${file.name}`}
className="grid h-8 w-8 shrink-0 place-items-center rounded-lg text-slate-400 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-white dark:hover:bg-rose-500/10 dark:hover:text-rose-300 dark:focus-visible:ring-offset-slate-950"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
</motion.div>
)}
</AnimatePresence>
</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 →
Empty No Data
Originalempty data state with illustration

Empty No Results
Originalno results with clear filters

Empty Cart
Originalempty shopping cart state

Empty Inbox
Originalempty inbox / all caught up

Empty First Run
Originalfirst-run get started empty state

Empty Notifications
Originalno notifications state

Empty Error
Originalsomething went wrong empty state

Empty No Connection
Originalno connection empty state

Empty Success
Originalsuccess / done empty state

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.

