Web InnoventixFreeCode

Upload Dropzone Form

Original · free

drag-and-drop file upload with preview

byWeb InnoventixReact + Tailwind
formuploaddropzoneforms
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/form-upload-dropzone.json
form-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 quote

Similar components

Browse all →