{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"Animated drag-and-drop file upload component","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useRef, useState } from \"react\";\n\nexport interface AnimatedFileUploadProps {\n  accept?: string;\n  className?: string;\n  disabled?: boolean;\n  maxSize?: number;\n  multiple?: boolean;\n  onFilesSelected: (files: File[]) => void;\n}\n\n/* ─────────────────────────────────────────────────────────\n * ANIMATION STORYBOARD\n *\n *    0ms   drop zone visible, dashed border idle\n *  drag    zone scales 1.02, border goes primary, icon floats up\n *  drop    icon bounces back, file rows slide in staggered\n * remove   file row slides out right + fades\n * layout   remaining files reorder with layout animation\n * ───────────────────────────────────────────────────────── */\n\nconst SPRING = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\n\nconst SPRING_BOUNCY = {\n  bounce: 0.2,\n  duration: 0.3,\n  type: \"spring\" as const,\n};\n\nfunction formatFileSize(bytes: number): string {\n  if (bytes < 1024) {\n    return `${bytes} B`;\n  }\n  if (bytes < 1024 * 1024) {\n    return `${(bytes / 1024).toFixed(1)} KB`;\n  }\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction FileIcon() {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"h-4 w-4 text-muted-foreground\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      viewBox=\"0 0 24 24\"\n    >\n      <path\n        d=\"M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction UploadIcon({ isDragOver }: { isDragOver: boolean }) {\n  const shouldReduceMotion = useReducedMotion();\n\n  return (\n    <motion.div\n      animate={\n        shouldReduceMotion\n          ? undefined\n          : isDragOver\n            ? { scale: 1.15, y: -4 }\n            : { scale: 1, y: 0 }\n      }\n      transition={shouldReduceMotion ? { duration: 0 } : SPRING_BOUNCY}\n    >\n      <svg\n        aria-hidden=\"true\"\n        className={cn(\n          \"mb-3 h-10 w-10 transition-colors duration-200\",\n          isDragOver ? \"text-foreground\" : \"text-muted-foreground\"\n        )}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.5}\n        viewBox=\"0 0 24 24\"\n      >\n        <path\n          d=\"M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n      </svg>\n    </motion.div>\n  );\n}\n\nexport default function AnimatedFileUpload({\n  onFilesSelected,\n  accept,\n  multiple = true,\n  maxSize,\n  className,\n  disabled = false,\n}: AnimatedFileUploadProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const [isDragOver, setIsDragOver] = useState(false);\n  const [files, setFiles] = useState<File[]>([]);\n  const [error, setError] = useState<string | null>(null);\n  const dragCounter = useRef(0);\n\n  const validateFiles = useCallback(\n    (newFiles: File[]): File[] => {\n      setError(null);\n      if (maxSize) {\n        const oversized = newFiles.filter((f) => f.size > maxSize);\n        if (oversized.length > 0) {\n          setError(\n            `${oversized.length} file(s) exceed the ${formatFileSize(maxSize)} limit`\n          );\n          return newFiles.filter((f) => f.size <= maxSize);\n        }\n      }\n      return newFiles;\n    },\n    [maxSize]\n  );\n\n  const handleFiles = useCallback(\n    (newFiles: File[]) => {\n      const valid = validateFiles(newFiles);\n      if (valid.length === 0) {\n        return;\n      }\n      const updated = multiple ? [...files, ...valid] : valid.slice(0, 1);\n      setFiles(updated);\n      onFilesSelected(updated);\n    },\n    [files, multiple, onFilesSelected, validateFiles]\n  );\n\n  const removeFile = useCallback(\n    (index: number) => {\n      const updated = files.filter((_, i) => i !== index);\n      setFiles(updated);\n      onFilesSelected(updated);\n    },\n    [files, onFilesSelected]\n  );\n\n  const handleDragEnter = useCallback(\n    (e: React.DragEvent) => {\n      e.preventDefault();\n      if (disabled) {\n        return;\n      }\n      dragCounter.current += 1;\n      setIsDragOver(true);\n    },\n    [disabled]\n  );\n\n  const handleDragLeave = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    dragCounter.current -= 1;\n    if (dragCounter.current === 0) {\n      setIsDragOver(false);\n    }\n  }, []);\n\n  const handleDragOver = useCallback(\n    (e: React.DragEvent) => {\n      e.preventDefault();\n      if (disabled) {\n      }\n    },\n    [disabled]\n  );\n\n  const handleDrop = useCallback(\n    (e: React.DragEvent) => {\n      e.preventDefault();\n      dragCounter.current = 0;\n      setIsDragOver(false);\n      if (disabled) {\n        return;\n      }\n      const droppedFiles = Array.from(e.dataTransfer.files);\n      handleFiles(droppedFiles);\n    },\n    [disabled, handleFiles]\n  );\n\n  const handleClick = () => {\n    if (!disabled) {\n      inputRef.current?.click();\n    }\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if ((e.key === \"Enter\" || e.key === \" \") && !disabled) {\n      e.preventDefault();\n      inputRef.current?.click();\n    }\n  };\n\n  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const selected = e.target.files ? Array.from(e.target.files) : [];\n    handleFiles(selected);\n    if (inputRef.current) {\n      inputRef.current.value = \"\";\n    }\n  };\n\n  return (\n    <div className={cn(\"w-full space-y-3\", className)}>\n      <motion.div\n        animate={\n          shouldReduceMotion\n            ? undefined\n            : isDragOver\n              ? { scale: 1.02 }\n              : { scale: 1 }\n        }\n        aria-label=\"File upload area. Drag and drop files or press to browse\"\n        className={cn(\n          \"relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-6 py-10\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n          \"transition-colors duration-200\",\n          isDragOver\n            ? \"border-primary bg-primary/5\"\n            : \"border-muted-foreground/25 hover:border-muted-foreground/40 hover:bg-muted/30\",\n          disabled && \"pointer-events-none opacity-50\"\n        )}\n        onClick={handleClick}\n        onDragEnter={handleDragEnter}\n        onDragLeave={handleDragLeave}\n        onDragOver={handleDragOver}\n        onDrop={handleDrop}\n        onKeyDown={handleKeyDown}\n        role=\"button\"\n        tabIndex={disabled ? -1 : 0}\n        transition={shouldReduceMotion ? { duration: 0 } : SPRING}\n      >\n        <input\n          accept={accept}\n          className=\"sr-only\"\n          disabled={disabled}\n          multiple={multiple}\n          onChange={handleInputChange}\n          ref={inputRef}\n          type=\"file\"\n        />\n\n        <UploadIcon isDragOver={isDragOver} />\n\n        <AnimatePresence initial={false} mode=\"wait\">\n          <motion.p\n            animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}\n            className=\"font-medium text-foreground text-sm\"\n            exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -4 }}\n            initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}\n            key={isDragOver ? \"drop\" : \"idle\"}\n            transition={\n              shouldReduceMotion ? { duration: 0 } : { duration: 0.15 }\n            }\n          >\n            {isDragOver ? \"Drop files here\" : \"Drag & drop or click to upload\"}\n          </motion.p>\n        </AnimatePresence>\n        <p className=\"mt-1 text-muted-foreground text-xs\">\n          {accept ? accept.replace(/,/g, \", \") : \"Any file type\"}\n          {maxSize ? ` \\u2022 Max ${formatFileSize(maxSize)}` : null}\n        </p>\n      </motion.div>\n\n      <AnimatePresence>\n        {error ? (\n          <motion.p\n            animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}\n            className=\"text-destructive text-sm\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { opacity: 0, y: -4 }\n            }\n            initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}\n            role=\"alert\"\n            transition={shouldReduceMotion ? { duration: 0 } : SPRING}\n          >\n            {error}\n          </motion.p>\n        ) : null}\n      </AnimatePresence>\n\n      {files.length > 0 && (\n        <ul aria-label=\"Selected files\" className=\"space-y-2\">\n          <AnimatePresence initial={false}>\n            {files.map((file, index) => (\n              <motion.li\n                animate={\n                  shouldReduceMotion\n                    ? { opacity: 1 }\n                    : { opacity: 1, scale: 1, x: 0 }\n                }\n                className=\"flex items-center gap-3 rounded-md border bg-muted/50 px-3 py-2\"\n                exit={\n                  shouldReduceMotion\n                    ? { opacity: 0, transition: { duration: 0 } }\n                    : {\n                        opacity: 0,\n                        scale: 0.95,\n                        transition: { duration: 0.15 },\n                        x: 24,\n                      }\n                }\n                initial={\n                  shouldReduceMotion\n                    ? { opacity: 0 }\n                    : { opacity: 0, scale: 0.95, x: -16 }\n                }\n                key={`${file.name}-${file.size}-${file.lastModified}`}\n                layout={!shouldReduceMotion}\n                transition={shouldReduceMotion ? { duration: 0 } : SPRING}\n              >\n                <FileIcon />\n                <div className=\"min-w-0 flex-1\">\n                  <p className=\"truncate font-medium text-foreground text-sm\">\n                    {file.name}\n                  </p>\n                  <p className=\"text-muted-foreground text-xs\">\n                    {formatFileSize(file.size)}\n                  </p>\n                </div>\n                <motion.button\n                  aria-label={`Remove ${file.name}`}\n                  className=\"shrink-0 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                  onClick={(e) => {\n                    e.stopPropagation();\n                    removeFile(index);\n                  }}\n                  type=\"button\"\n                  whileHover={shouldReduceMotion ? undefined : { scale: 1.1 }}\n                  whileTap={shouldReduceMotion ? undefined : { scale: 0.9 }}\n                >\n                  <svg\n                    aria-hidden=\"true\"\n                    className=\"h-4 w-4\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth={2}\n                    viewBox=\"0 0 24 24\"\n                  >\n                    <path\n                      d=\"M6 18L18 6M6 6l12 12\"\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                    />\n                  </svg>\n                </motion.button>\n              </motion.li>\n            ))}\n          </AnimatePresence>\n        </ul>\n      )}\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/animated-file-upload/index.tsx","type":"registry:ui"}],"name":"animated-file-upload","registryDependencies":[],"title":"Animated File Upload","type":"registry:ui"}