{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"file-upload","type":"registry:block","title":"File Upload","description":"Drag-and-drop upload queue with progress rows, retry/remove actions and reduced-motion-safe state changes.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/file-upload.tsx","type":"registry:component","target":"@components/motion/file-upload.tsx","content":"\"use client\";\n\nimport {\n  AlertCircle,\n  CheckCircle2,\n  FileArchive,\n  FileAudio,\n  FileCode2,\n  FileIcon,\n  FileImage,\n  FileSpreadsheet,\n  FileText,\n  FileVideo,\n  Loader2,\n  RotateCcw,\n  UploadCloud,\n  X,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useId, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport type FileUploadStatus = \"queued\" | \"uploading\" | \"success\" | \"error\";\nexport type FileUploadVariant = \"default\" | \"centered\";\n\nexport type FileUploadItem = {\n  id: string;\n  name: string;\n  size: number;\n  type?: string;\n  progress?: number;\n  status?: FileUploadStatus;\n  error?: string;\n  file?: File;\n};\n\nexport type FileUploadClassNames = {\n  root?: string;\n  dropzone?: string;\n  queue?: string;\n  item?: string;\n  leading?: string;\n  content?: string;\n  name?: string;\n  meta?: string;\n  progress?: string;\n  action?: string;\n};\n\nexport interface FileUploadProps {\n  value?: FileUploadItem[];\n  defaultValue?: FileUploadItem[];\n  onValueChange?: (items: FileUploadItem[]) => void;\n  onFilesAdded?: (items: FileUploadItem[], files: File[]) => void;\n  onRemove?: (item: FileUploadItem) => void;\n  onRetry?: (item: FileUploadItem) => void;\n  accept?: string;\n  multiple?: boolean;\n  maxFiles?: number;\n  disabled?: boolean;\n  variant?: FileUploadVariant;\n  title?: string;\n  description?: string;\n  browseLabel?: string;\n  className?: string;\n  classNames?: FileUploadClassNames;\n}\n\nconst ROW_TRANSITION = { duration: 0.22, ease: EASE_OUT } as const;\nconst FAST_TRANSITION = { duration: 0.16, ease: EASE_OUT } as const;\n\nconst STATUS_LABEL: Record<FileUploadStatus, string> = {\n  queued: \"Queued\",\n  uploading: \"Uploading\",\n  success: \"Uploaded\",\n  error: \"Failed\",\n};\n\nconst STATUS_TONE: Record<FileUploadStatus, string> = {\n  queued: \"text-muted-foreground\",\n  uploading: \"text-foreground\",\n  success: \"text-emerald-600 dark:text-emerald-400\",\n  error: \"text-destructive\",\n};\n\nfunction useControllableUpload({\n  value,\n  defaultValue,\n  onValueChange,\n}: {\n  value?: FileUploadItem[];\n  defaultValue?: FileUploadItem[];\n  onValueChange?: (items: FileUploadItem[]) => void;\n}) {\n  const [internalValue, setInternalValue] = useState(defaultValue ?? []);\n  const isControlled = value !== undefined;\n  const items = value ?? internalValue;\n\n  const setItems = useCallback(\n    (next: FileUploadItem[]) => {\n      if (!isControlled) {\n        setInternalValue(next);\n      }\n\n      onValueChange?.(next);\n    },\n    [isControlled, onValueChange],\n  );\n\n  return [items, setItems] as const;\n}\n\nfunction clampProgress(value: number | undefined, status: FileUploadStatus) {\n  if (status === \"success\") return 100;\n  if (value === undefined || Number.isNaN(value)) return 0;\n  return Math.max(0, Math.min(100, value));\n}\n\nfunction formatBytes(bytes: number) {\n  if (!Number.isFinite(bytes) || bytes <= 0) return \"0 B\";\n\n  const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n  const exponent = Math.min(\n    Math.floor(Math.log(bytes) / Math.log(1024)),\n    units.length - 1,\n  );\n  const value = bytes / 1024 ** exponent;\n\n  return `${value >= 10 || exponent === 0 ? value.toFixed(0) : value.toFixed(1)} ${\n    units[exponent]\n  }`;\n}\n\nfunction fileKind(item: FileUploadItem) {\n  const extension = item.name.includes(\".\")\n    ? item.name.split(\".\").pop()\n    : undefined;\n\n  if (extension) return extension.toUpperCase();\n  if (item.type) return item.type.split(\"/\").pop()?.toUpperCase();\n  return \"FILE\";\n}\n\nfunction getFileIcon(item: FileUploadItem) {\n  const extension = item.name.includes(\".\")\n    ? item.name.split(\".\").pop()?.toLowerCase()\n    : undefined;\n  const type = item.type ?? \"\";\n\n  if (type.startsWith(\"image/\")) return FileImage;\n  if (type.startsWith(\"video/\")) return FileVideo;\n  if (type.startsWith(\"audio/\")) return FileAudio;\n  if (\n    type.includes(\"zip\") ||\n    type.includes(\"compressed\") ||\n    [\"zip\", \"rar\", \"7z\", \"tar\", \"gz\"].includes(extension ?? \"\")\n  ) {\n    return FileArchive;\n  }\n  if (\n    type.includes(\"spreadsheet\") ||\n    type.includes(\"excel\") ||\n    [\"csv\", \"xls\", \"xlsx\"].includes(extension ?? \"\")\n  ) {\n    return FileSpreadsheet;\n  }\n  if (\n    type.includes(\"pdf\") ||\n    type.startsWith(\"text/\") ||\n    [\"pdf\", \"doc\", \"docx\", \"md\", \"txt\"].includes(extension ?? \"\")\n  ) {\n    return FileText;\n  }\n  if (\n    [\n      \"css\",\n      \"html\",\n      \"js\",\n      \"jsx\",\n      \"json\",\n      \"mdx\",\n      \"ts\",\n      \"tsx\",\n      \"xml\",\n      \"yaml\",\n      \"yml\",\n    ].includes(extension ?? \"\")\n  ) {\n    return FileCode2;\n  }\n\n  return FileIcon;\n}\n\nexport function createFileUploadItem(file: File, index = 0): FileUploadItem {\n  return {\n    id: `${Date.now()}-${index}-${file.name}`,\n    name: file.name,\n    size: file.size,\n    type: file.type,\n    progress: 0,\n    status: \"uploading\",\n    file,\n  };\n}\n\nfunction StatusIcon({\n  status,\n  reduce,\n}: {\n  status: FileUploadStatus;\n  reduce: boolean;\n}) {\n  const iconClassName = \"h-4 w-4\";\n\n  return (\n    <AnimatePresence mode=\"wait\" initial={false}>\n      <motion.span\n        key={status}\n        initial={\n          reduce\n            ? { opacity: 0 }\n            : { opacity: 0, transform: \"translateY(4px)\" }\n        }\n        animate={{ opacity: 1, transform: \"translateY(0px)\" }}\n        exit={\n          reduce\n            ? { opacity: 0 }\n            : { opacity: 0, transform: \"translateY(-4px)\" }\n        }\n        transition={FAST_TRANSITION}\n        className={cn(\"grid h-6 w-6 place-items-center\", STATUS_TONE[status])}\n      >\n        {status === \"success\" ? (\n          <CheckCircle2 className={iconClassName} />\n        ) : status === \"error\" ? (\n          <AlertCircle className={iconClassName} />\n        ) : status === \"uploading\" ? (\n          <Loader2\n            className={cn(\n              iconClassName,\n              \"animate-spin\",\n              reduce && \"animate-none\",\n            )}\n          />\n        ) : (\n          <FileIcon className={iconClassName} />\n        )}\n        <span className=\"sr-only\">{STATUS_LABEL[status]}</span>\n      </motion.span>\n    </AnimatePresence>\n  );\n}\n\nfunction FileUploadRow({\n  item,\n  onRemove,\n  onRetry,\n  classNames,\n}: {\n  item: FileUploadItem;\n  onRemove: (item: FileUploadItem) => void;\n  onRetry: (item: FileUploadItem) => void;\n  classNames?: FileUploadClassNames;\n}) {\n  const reduce = useReducedMotion() ?? false;\n  const status = item.status ?? \"queued\";\n  const progress = clampProgress(item.progress, status);\n  const progressRatio = progress / 100;\n  const showProgress = status === \"uploading\" || status === \"success\";\n  const LeadingIcon = getFileIcon(item);\n\n  return (\n    <motion.li\n      layout={!reduce}\n      initial={\n        reduce ? { opacity: 0 } : { opacity: 0, transform: \"translateY(8px)\" }\n      }\n      animate={{ opacity: 1, transform: \"translateY(0px)\" }}\n      exit={\n        reduce ? { opacity: 0 } : { opacity: 0, transform: \"translateY(-6px)\" }\n      }\n      transition={ROW_TRANSITION}\n      className={cn(\n        \"relative overflow-hidden rounded-2xl border border-border bg-background p-3\",\n        classNames?.item,\n      )}\n    >\n      <div className=\"flex items-center gap-3\">\n        <div\n          className={cn(\n            \"grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-muted text-muted-foreground\",\n            classNames?.leading,\n          )}\n        >\n          <LeadingIcon className=\"h-5 w-5\" />\n        </div>\n\n        <div className={cn(\"min-w-0 flex-1\", classNames?.content)}>\n          <div className=\"flex items-start justify-between gap-3\">\n            <div className=\"min-w-0\">\n              <p\n                className={cn(\n                  \"truncate text-sm font-medium text-foreground\",\n                  classNames?.name,\n                )}\n              >\n                {item.name}\n              </p>\n              <p\n                className={cn(\n                  \"mt-0.5 text-xs text-muted-foreground\",\n                  classNames?.meta,\n                )}\n              >\n                {fileKind(item)} · {formatBytes(item.size)}\n                {status === \"error\" && item.error ? ` · ${item.error}` : null}\n              </p>\n            </div>\n\n            <div className=\"flex shrink-0 items-center gap-1\">\n              <StatusIcon status={status} reduce={reduce} />\n              {status === \"error\" ? (\n                <button\n                  type=\"button\"\n                  onClick={() => onRetry(item)}\n                  aria-label={`Retry ${item.name}`}\n                  className={cn(\n                    \"grid h-7 w-7 place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground active:scale-95\",\n                    classNames?.action,\n                  )}\n                >\n                  <RotateCcw className=\"h-3.5 w-3.5\" />\n                </button>\n              ) : null}\n              <button\n                type=\"button\"\n                onClick={() => onRemove(item)}\n                aria-label={`Remove ${item.name}`}\n                className={cn(\n                  \"grid h-7 w-7 place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground active:scale-95\",\n                  classNames?.action,\n                )}\n              >\n                <X className=\"h-3.5 w-3.5\" />\n              </button>\n            </div>\n          </div>\n\n          {showProgress ? (\n            <div\n              role=\"progressbar\"\n              aria-valuemin={0}\n              aria-valuemax={100}\n              aria-valuenow={Math.round(progress)}\n              aria-label={`${item.name} upload progress`}\n              className={cn(\n                \"mt-3 h-1.5 overflow-hidden rounded-full bg-muted\",\n                classNames?.progress,\n              )}\n            >\n              <motion.div\n                className={cn(\n                  \"h-full rounded-full\",\n                  status === \"success\"\n                    ? \"bg-emerald-500\"\n                    : \"bg-foreground\",\n                )}\n                style={{\n                  transformOrigin: \"left\",\n                  transform: reduce ? `scaleX(${progressRatio})` : undefined,\n                }}\n                initial={false}\n                animate={\n                  reduce ? undefined : { transform: `scaleX(${progressRatio})` }\n                }\n                transition={{ duration: 0.28, ease: EASE_OUT }}\n              />\n            </div>\n          ) : null}\n        </div>\n      </div>\n    </motion.li>\n  );\n}\n\nexport function FileUpload({\n  value,\n  defaultValue,\n  onValueChange,\n  onFilesAdded,\n  onRemove,\n  onRetry,\n  accept,\n  multiple = true,\n  maxFiles,\n  disabled = false,\n  variant = \"default\",\n  title = \"Drop files here\",\n  description = \"Add files to the upload queue\",\n  browseLabel = \"Browse\",\n  className,\n  classNames,\n}: FileUploadProps) {\n  const inputId = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const dragDepthRef = useRef(0);\n  const reduce = useReducedMotion() ?? false;\n  const [items, setItems] = useControllableUpload({\n    value,\n    defaultValue,\n    onValueChange,\n  });\n  const [dragging, setDragging] = useState(false);\n\n  const commit = useCallback(\n    (next: FileUploadItem[]) => {\n      setItems(next);\n    },\n    [setItems],\n  );\n\n  const addFiles = useCallback(\n    (incomingFiles: File[]) => {\n      if (disabled || incomingFiles.length === 0) return;\n\n      const remainingSlots =\n        maxFiles === undefined ? incomingFiles.length : maxFiles - items.length;\n      if (remainingSlots <= 0) return;\n\n      const files = incomingFiles.slice(\n        0,\n        multiple ? remainingSlots : Math.min(1, remainingSlots),\n      );\n      const added = files.map((file, index) => createFileUploadItem(file, index));\n\n      if (added.length === 0) return;\n\n      commit([...items, ...added]);\n      onFilesAdded?.(added, files);\n    },\n    [commit, disabled, items, maxFiles, multiple, onFilesAdded],\n  );\n\n  const removeItem = useCallback(\n    (item: FileUploadItem) => {\n      commit(items.filter((entry) => entry.id !== item.id));\n      onRemove?.(item);\n    },\n    [commit, items, onRemove],\n  );\n\n  const retryItem = useCallback(\n    (item: FileUploadItem) => {\n      const retryingItem = {\n        ...item,\n        error: undefined,\n        progress: 0,\n        status: \"uploading\" as const,\n      };\n\n      commit(\n        items.map((entry) => (entry.id === item.id ? retryingItem : entry)),\n      );\n      onRetry?.(retryingItem);\n    },\n    [commit, items, onRetry],\n  );\n\n  const resetDrag = useCallback(() => {\n    dragDepthRef.current = 0;\n    setDragging(false);\n  }, []);\n\n  const maxReached = maxFiles !== undefined && items.length >= maxFiles;\n  const centered = variant === \"centered\";\n\n  return (\n    <div className={cn(\"w-full space-y-3\", className, classNames?.root)}>\n      <input\n        ref={inputRef}\n        id={inputId}\n        type=\"file\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled || maxReached}\n        tabIndex={-1}\n        className=\"sr-only\"\n        onChange={(event) => {\n          addFiles(Array.from(event.currentTarget.files ?? []));\n          event.currentTarget.value = \"\";\n        }}\n      />\n\n      <button\n        type=\"button\"\n        disabled={disabled || maxReached}\n        data-dragging={dragging}\n        onClick={() => inputRef.current?.click()}\n        onDragEnter={(event) => {\n          if (disabled || maxReached) return;\n          event.preventDefault();\n          dragDepthRef.current += 1;\n          setDragging(true);\n        }}\n        onDragOver={(event) => {\n          if (disabled || maxReached) return;\n          event.preventDefault();\n          event.dataTransfer.dropEffect = \"copy\";\n          setDragging(true);\n        }}\n        onDragLeave={(event) => {\n          if (disabled || maxReached) return;\n          event.preventDefault();\n          dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);\n          if (dragDepthRef.current === 0) setDragging(false);\n        }}\n        onDrop={(event) => {\n          if (disabled || maxReached) return;\n          event.preventDefault();\n          resetDrag();\n          addFiles(Array.from(event.dataTransfer.files));\n        }}\n        className={cn(\n          \"group relative flex w-full overflow-hidden rounded-3xl border border-dashed border-border bg-background outline-none\",\n          \"transition-[border-color,transform] duration-200 active:scale-[0.99]\",\n          \"hover:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          \"data-[dragging=true]:border-foreground\",\n          \"disabled:pointer-events-none disabled:opacity-55\",\n          centered\n            ? \"min-h-56 flex-col items-center justify-center gap-3 p-7 text-center\"\n            : \"items-center gap-4 p-5 text-left\",\n          classNames?.dropzone,\n        )}\n      >\n        <motion.span\n          aria-hidden=\"true\"\n          className={cn(\n            \"grid shrink-0 place-items-center bg-muted text-foreground\",\n            centered\n              ? \"h-16 w-16 rounded-[1.35rem] border border-border\"\n              : \"h-14 w-14 rounded-[1.25rem]\",\n          )}\n          animate={\n            reduce\n              ? undefined\n              : {\n                  transform: dragging\n                    ? \"translateY(-2px)\"\n                    : \"translateY(0px)\",\n                }\n          }\n          transition={FAST_TRANSITION}\n        >\n          <UploadCloud className={centered ? \"h-7 w-7\" : \"h-6 w-6\"} />\n        </motion.span>\n\n        <span className={cn(\"min-w-0\", centered ? \"max-w-xs\" : \"flex-1\")}>\n          <span\n            className={cn(\n              \"block font-semibold text-foreground\",\n              centered ? \"text-base\" : \"text-sm\",\n            )}\n          >\n            {maxReached ? \"Upload limit reached\" : title}\n          </span>\n          <span\n            className={cn(\n              \"block text-xs text-muted-foreground\",\n              centered ? \"mt-1 leading-5\" : \"mt-0.5\",\n            )}\n          >\n            {maxReached\n              ? `${items.length} of ${maxFiles} files added`\n              : description}\n          </span>\n        </span>\n\n        <span\n          className={cn(\n            \"shrink-0 rounded-full border border-border text-xs font-medium text-foreground transition-colors duration-150 group-hover:bg-muted\",\n            centered ? \"mt-1 px-4 py-2\" : \"px-3.5 py-2\",\n          )}\n        >\n          {browseLabel}\n        </span>\n      </button>\n\n      <div className={cn(\"space-y-2\", classNames?.queue)}>\n        <AnimatePresence initial={false}>\n          {items.map((item) => (\n            <FileUploadRow\n              key={item.id}\n              item={item}\n              onRemove={removeItem}\n              onRetry={retryItem}\n              classNames={classNames}\n            />\n          ))}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}