Web InnoventixFreeCode

Empty Folder

Original · free

empty folder with upload CTA

byWeb InnoventixReact + Tailwind
emptyfolderstates
Open

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
empty-folder.tsx
"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"} &middot; {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

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 quote

Similar components

Browse all →