# File Upload Dropzone — React File Component

> A dropzone that lifts and glows while files hover over it, then lists each file with its own simulated progress bar, a drawn check on completion and a remove button.

- Category: [Files & Uploads](https://pixelpapa.com/components/files.md)
- Tags: upload, dropzone, drag and drop, progress, files
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/files/file-upload-dropzone

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/file-upload-dropzone.json"
```

The file is written to `components/pixelpapa/file-upload-dropzone.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { CloudUpload, FileText, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useId, useRef, useState, type DragEvent } from "react";
import { cn } from "@/lib/utils";

export type UploadItem = { id: string; name: string; size: number; progress: number; error?: string };

type UploadDropzoneProps = {
  /** Files shown on mount, e.g. uploads already in flight. */
  initial?: UploadItem[];
  /** Maximum size in bytes; larger files are listed with an error. */
  maxSize?: number;
  hint?: string;
  onFiles?: (files: File[]) => void;
  className?: string;
};

const fmt = (bytes: number) => (bytes >= 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1e3))} KB`);

export function UploadDropzone({ initial = [], maxSize = 25e6, hint = "PDF, PNG or CSV up to 25 MB", onFiles, className }: UploadDropzoneProps) {
  const inputId = useId();
  const [items, setItems] = useState(initial);
  const [over, setOver] = useState(false);
  const depth = useRef(0); // dragenter/leave fire for children too
  const count = useRef(0);
  const busy = items.some((i) => !i.error && i.progress < 100);

  // Simulated transfer: bigger files move slower. Swap for real upload progress.
  useEffect(() => {
    if (!busy) return;
    const t = setInterval(() => {
      setItems((prev) => prev.map((i) => (i.error || i.progress >= 100 ? i : { ...i, progress: Math.min(100, i.progress + Math.max(2, 9e6 / (i.size + 4e5))) })));
    }, 140);
    return () => clearInterval(t);
  }, [busy]);

  function add(files: File[]) {
    if (!files.length) return;
    onFiles?.(files);
    setItems((prev) => [
      ...files.map((f) => ({
        id: `${inputId}-${count.current++}`,
        name: f.name,
        size: f.size,
        progress: 0,
        error: f.size > maxSize ? `Larger than ${fmt(maxSize)}` : undefined,
      })),
      ...prev,
    ]);
  }

  function onDrop(e: DragEvent<HTMLLabelElement>) {
    e.preventDefault();
    depth.current = 0;
    setOver(false);
    add([...e.dataTransfer.files]);
  }

  return (
    <div className={cn("w-full", className)}>
      <motion.label
        htmlFor={inputId}
        onDragEnter={(e) => {
          e.preventDefault();
          depth.current++;
          setOver(true);
        }}
        onDragOver={(e) => e.preventDefault()}
        onDragLeave={() => --depth.current <= 0 && setOver(false)}
        onDrop={onDrop}
        animate={{ scale: over ? 1.015 : 1 }}
        transition={{ type: "spring", stiffness: 400, damping: 26 }}
        className={cn(
          "group relative flex cursor-pointer flex-col items-center rounded-2xl border-2 border-dashed px-6 py-7 text-center transition-colors focus-within:border-primary focus-within:ring-4 focus-within:ring-ring/15",
          over ? "border-primary bg-primary/[0.07]" : "border-input bg-card hover:border-primary/50 hover:bg-muted/40",
        )}
      >
        <input id={inputId} type="file" multiple className="sr-only" onChange={(e) => (add([...(e.target.files ?? [])]), (e.target.value = ""))} />
        <motion.span
          animate={{ y: over ? -6 : 0 }}
          transition={{ type: "spring", stiffness: 300, damping: 14 }}
          className={cn(
            "flex size-11 items-center justify-center rounded-xl transition-colors",
            over ? "bg-primary text-primary-foreground shadow-lg shadow-primary/30" : "bg-muted text-muted-foreground group-hover:text-primary",
          )}
        >
          <CloudUpload className="size-5" />
        </motion.span>
        <p className="mt-3 text-sm font-medium text-foreground">
          {over ? "Drop to upload" : (
            <>
              Drag files here or <span className="text-primary underline-offset-4 group-hover:underline">browse</span>
            </>
          )}
        </p>
        <p className="mt-1 text-xs text-muted-foreground">{hint}</p>
      </motion.label>

      <ul className="mt-3 space-y-2">
        <AnimatePresence initial={false}>
          {items.map((item) => {
            const done = item.progress >= 100;
            return (
              <motion.li
                key={item.id}
                layout
                initial={{ opacity: 0, y: -10, scale: 0.97 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.15 } }}
                transition={{ type: "spring", stiffness: 420, damping: 34 }}
                className="flex items-center gap-3 rounded-xl border border-border bg-card py-2.5 pr-2 pl-3"
              >
                <span className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", item.error ? "bg-destructive/10 text-destructive" : "bg-primary/10 text-primary")}>
                  <FileText className="size-4" />
                </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-card-foreground">{item.name}</p>
                    <p className={cn("shrink-0 text-xs tabular-nums", item.error ? "text-destructive" : "text-muted-foreground")}>
                      {item.error ?? (done ? fmt(item.size) : `${Math.round(item.progress)}%`)}
                    </p>
                  </div>
                  <div
                    role="progressbar"
                    aria-label={`Uploading ${item.name}`}
                    aria-valuemin={0}
                    aria-valuemax={100}
                    aria-valuenow={Math.round(item.progress)}
                    className="mt-1.5 h-1 overflow-hidden rounded-full bg-muted"
                  >
                    <motion.div
                      initial={false}
                      animate={{ width: `${item.error ? 100 : item.progress}%` }}
                      transition={{ ease: "linear", duration: 0.14 }}
                      className={cn("h-full rounded-full transition-colors duration-300", item.error ? "bg-destructive/50" : done ? "bg-emerald-500" : "bg-primary")}
                    />
                  </div>
                </div>
                <button
                  type="button"
                  aria-label={`Remove ${item.name}`}
                  onClick={() => setItems((prev) => prev.filter((i) => i.id !== item.id))}
                  className="group/x relative flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60"
                >
                  {done && !item.error && (
                    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" className="absolute size-4 text-emerald-500 transition-opacity group-hover/x:opacity-0 group-focus-visible/x:opacity-0">
                      <motion.path d="M3.5 8.4l3 3 6-6.6" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.3 }} />
                    </svg>
                  )}
                  <X className={cn("size-4", done && !item.error && "opacity-0 transition-opacity group-hover/x:opacity-100 group-focus-visible/x:opacity-100")} />
                </button>
              </motion.li>
            );
          })}
        </AnimatePresence>
      </ul>
    </div>
  );
}

const seeded: UploadItem[] = [
  { id: "seed-1", name: "q3-board-deck.pdf", size: 8.4e6, progress: 18 },
  { id: "seed-2", name: "customers-export.csv", size: 1.9e6, progress: 64 },
  { id: "seed-3", name: "warehouse-walkthrough.mov", size: 212e6, progress: 0, error: "Larger than 25.0 MB" },
];

export default function FileUploadDropzoneDemo() {
  return <UploadDropzone initial={seeded} className="max-w-md" />;
}
```

## AI prompt

```text
Add the "File Upload Dropzone" component from PixelPapa UI to this project.

What it is: A dropzone that lifts and glows while files hover over it, then lists each file with its own simulated progress bar, a drawn check on completion and a remove button.
Category: Files & Uploads. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/file-upload-dropzone.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/file-upload-dropzone.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/file-upload-dropzone" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More files & uploads

- [File Attachment Chips](https://pixelpapa.com/components/files/file-attachment-chips.md): Attachment chips with a type-coloured icon and size; new chips pop in, removed ones collapse, and the rest reflow with spring layout animation.
- [File List Progress](https://pixelpapa.com/components/files/file-list-progress.md) (Pro): An upload queue with pause, resume, cancel and retry: two files transfer at a time, queued ones start on their own, and finished rows settle with a drawn check.
- [File Preview Grid](https://pixelpapa.com/components/files/file-preview-grid.md): A grid of file tiles colour-coded by type, each with a drawn preview; tiles lift on hover to reveal actions and a selection count rolls in the header.
- [File Tree Explorer](https://pixelpapa.com/components/files/file-tree-explorer.md) (Pro): A file tree with spring-animated folders, indent guides and full arrow-key navigation; the selection highlight slides between rows.
