Inputs & Forms

File Dropzone

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.

Install

npx shadcn@latest add @paragon/file-dropzone

file-dropzone.tsx

"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>
  );
}