{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"file-drop","type":"registry:ui","title":"File Drop","description":"An upload zone that wakes up under a dragged file: the dashed edge marches, the icon lifts, and dropped files slide into a list with their own progress bars that finish with a check.","dependencies":["motion","lucide-react"],"registryDependencies":["utils"],"files":[{"path":"registry/ui/file-drop.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { Check, FileUp, X } from \"lucide-react\";\nimport { AnimatePresence, MotionConfig, motion } from \"motion/react\";\nimport { useRef, useState, type DragEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface FileDropProps {\n  /** File types for the picker, e.g. \"image/*,.pdf\". */\n  accept?: string;\n  multiple?: boolean;\n  /** Uploads one file and reports progress from 0 to 1. Without it, files are listed as done. */\n  upload?: (file: File, onProgress: (p: number) => void) => Promise<void>;\n  /** Called with the files that were added. */\n  onFiles?: (files: File[]) => void;\n  title?: string;\n  hint?: string;\n  className?: string;\n}\n\ninterface Entry {\n  id: number;\n  file: File;\n  progress: number;\n  error?: boolean;\n}\n\nconst css = `\n@keyframes file-drop-march { to { stroke-dashoffset: -24 } }\n.file-drop-edge { animation: file-drop-march 0.8s linear infinite; animation-play-state: paused }\n.file-drop-zone[data-over=\"true\"] .file-drop-edge { animation-play-state: running }\n@media (prefers-reduced-motion: reduce) { .file-drop-edge { animation: none } }\n`;\n\nconst size = (b: number) => (b < 1024 ? `${b} B` : b < 1048576 ? `${(b / 1024).toFixed(0)} KB` : `${(b / 1048576).toFixed(1)} MB`);\n\n/**\n * An upload zone that wakes up while a file is dragged over it: the dashed edge\n * starts marching, the icon lifts, and dropped files slide into a list with\n * their own progress bars that finish with a check.\n */\nexport function FileDrop({\n  accept,\n  multiple = true,\n  upload,\n  onFiles,\n  title = \"Drop files here\",\n  hint = \"or click to browse\",\n  className,\n}: FileDropProps) {\n  const [over, setOver] = useState(false);\n  const [entries, setEntries] = useState<Entry[]>([]);\n  const picker = useRef<HTMLInputElement>(null);\n  const depth = useRef(0);\n  const nextId = useRef(0);\n\n  const setProgress = (id: number, patch: Partial<Entry>) =>\n    setEntries((list) => list.map((e) => (e.id === id ? { ...e, ...patch } : e)));\n\n  const add = (files: File[]) => {\n    if (!files.length) return;\n    const fresh = (multiple ? files : files.slice(0, 1)).map((file) => ({ id: nextId.current++, file, progress: upload ? 0 : 1 }));\n    setEntries((list) => (multiple ? [...list, ...fresh] : fresh));\n    onFiles?.(fresh.map((e) => e.file));\n    if (upload)\n      for (const e of fresh)\n        upload(e.file, (p) => setProgress(e.id, { progress: Math.min(1, Math.max(0, p)) })).then(\n          () => setProgress(e.id, { progress: 1 }),\n          () => setProgress(e.id, { error: true }),\n        );\n  };\n\n  const drag = (e: DragEvent, delta: number) => {\n    e.preventDefault();\n    depth.current = Math.max(0, depth.current + delta);\n    setOver(depth.current > 0);\n  };\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <style href=\"file-drop\" precedence=\"default\">\n        {css}\n      </style>\n      <div className={cn(\"grid gap-3\", className)}>\n        <button\n          type=\"button\"\n          data-over={over}\n          onClick={() => picker.current?.click()}\n          onDragEnter={(e) => drag(e, 1)}\n          onDragLeave={(e) => drag(e, -1)}\n          onDragOver={(e) => e.preventDefault()}\n          onDrop={(e) => {\n            e.preventDefault();\n            depth.current = 0;\n            setOver(false);\n            add([...e.dataTransfer.files]);\n          }}\n          className={cn(\n            \"file-drop-zone relative grid place-items-center gap-2 rounded-2xl px-6 py-8 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n            over ? \"bg-primary/10\" : \"bg-muted/40 hover:bg-muted/70\",\n          )}\n        >\n          <svg aria-hidden className=\"pointer-events-none absolute inset-0 size-full overflow-visible\">\n            <rect\n              className=\"file-drop-edge\"\n              x=\"1\"\n              y=\"1\"\n              style={{ width: \"calc(100% - 2px)\", height: \"calc(100% - 2px)\" }}\n              rx=\"15\"\n              fill=\"none\"\n              stroke={over ? \"var(--primary)\" : \"var(--border)\"}\n              strokeWidth=\"1.5\"\n              strokeDasharray=\"8 4\"\n            />\n          </svg>\n          <motion.span\n            className={cn(\"grid size-11 place-items-center rounded-xl\", over ? \"bg-primary text-primary-foreground\" : \"bg-card text-muted-foreground shadow-sm\")}\n            animate={over ? { y: -6, scale: 1.08, rotate: -6 } : { y: 0, scale: 1, rotate: 0 }}\n            transition={{ type: \"spring\", bounce: 0.5, duration: 0.45 }}\n          >\n            <FileUp className=\"size-5\" />\n          </motion.span>\n          <span className=\"text-sm font-semibold text-foreground\">{over ? \"Release to upload\" : title}</span>\n          <span className=\"text-xs text-muted-foreground\">{hint}</span>\n        </button>\n        <input\n          ref={picker}\n          type=\"file\"\n          accept={accept}\n          multiple={multiple}\n          className=\"sr-only\"\n          tabIndex={-1}\n          aria-hidden\n          onChange={(e) => {\n            add([...(e.target.files ?? [])]);\n            e.target.value = \"\";\n          }}\n        />\n\n        <ul className=\"grid gap-2\" aria-live=\"polite\">\n          <AnimatePresence initial={false}>\n            {entries.map((e) => {\n              const done = e.progress >= 1 && !e.error;\n              return (\n                <motion.li\n                  key={e.id}\n                  layout\n                  className=\"flex items-center gap-3 overflow-hidden rounded-xl border border-border bg-card px-3 py-2.5\"\n                  initial={{ opacity: 0, y: -8, filter: \"blur(3px)\" }}\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, x: 24, transition: { duration: 0.18 } }}\n                  transition={{ type: \"spring\", bounce: 0.25, duration: 0.4 }}\n                >\n                  <div className=\"min-w-0 flex-1\">\n                    <div className=\"flex items-baseline justify-between gap-2 text-xs\">\n                      <span className=\"truncate font-medium text-foreground\">{e.file.name}</span>\n                      <span className={cn(\"shrink-0 tabular-nums\", e.error ? \"text-destructive\" : \"text-muted-foreground\")}>\n                        {e.error ? \"Failed\" : done ? size(e.file.size) : `${Math.round(e.progress * 100)}%`}\n                      </span>\n                    </div>\n                    <div className=\"mt-1.5 h-1 overflow-hidden rounded-full bg-muted\">\n                      <motion.div\n                        className={cn(\"h-full origin-left rounded-full\", e.error ? \"bg-destructive\" : done ? \"bg-emerald-500\" : \"bg-primary\")}\n                        initial={false}\n                        animate={{ scaleX: e.error ? 1 : e.progress }}\n                        transition={{ type: \"spring\", bounce: 0, duration: 0.35 }}\n                      />\n                    </div>\n                  </div>\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    {done ? (\n                      <motion.span\n                        key=\"done\"\n                        className=\"grid size-6 shrink-0 place-items-center rounded-full bg-emerald-500 text-white\"\n                        initial={{ scale: 0, rotate: -45 }}\n                        animate={{ scale: 1, rotate: 0 }}\n                        transition={{ type: \"spring\", bounce: 0.55, duration: 0.4 }}\n                      >\n                        <Check className=\"size-3.5\" strokeWidth={3} />\n                      </motion.span>\n                    ) : (\n                      <motion.button\n                        key=\"remove\"\n                        type=\"button\"\n                        aria-label={`Remove ${e.file.name}`}\n                        onClick={() => setEntries((list) => list.filter((x) => x.id !== e.id))}\n                        className=\"grid size-6 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                        exit={{ scale: 0 }}\n                      >\n                        <X className=\"size-3.5\" />\n                      </motion.button>\n                    )}\n                  </AnimatePresence>\n                </motion.li>\n              );\n            })}\n          </AnimatePresence>\n        </ul>\n      </div>\n    </MotionConfig>\n  );\n}\n"}]}