{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload",
  "type": "registry:ui",
  "title": "File Upload",
  "description": "Accessible drag-and-drop file uploader with an animated folder and per-file progress states.",
  "dependencies": [
    "@gsap/react",
    "gsap",
    "@radix-ui/react-slot",
    "class-variance-authority",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils",
    "button"
  ],
  "files": [
    {
      "path": "src/registry/ui/file-upload.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useGSAP } from \"@gsap/react\";\nimport gsap from \"gsap\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ngsap.registerPlugin(useGSAP);\n\nexport type FileUploadStatus =\n  | \"queued\"\n  | \"uploading\"\n  | \"success\"\n  | \"error\"\n  | \"cancelled\";\n\nexport type FileUploadLayout = \"full\" | \"compact\";\n\nexport interface FileUploadItem {\n  id: string;\n  file: File;\n  progress: number;\n  status: FileUploadStatus;\n  error?: string;\n  retryable?: boolean;\n}\n\nexport interface FileUploadProps {\n  uploadFile: (\n    file: File,\n    onProgress: (progress: number) => void,\n    signal: AbortSignal,\n  ) => Promise<void>;\n  accept?: string;\n  multiple?: boolean;\n  maxFiles?: number;\n  maxSize?: number;\n  layout?: FileUploadLayout;\n  accent?: string;\n  disabled?: boolean;\n  className?: string;\n  onFilesChange?: (items: FileUploadItem[]) => void;\n}\n\nconst formatBytes = (bytes: number) => {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n};\n\nconst acceptsFile = (file: File, accept?: string) => {\n  if (!accept) return true;\n  return accept.split(\",\").some((entry) => {\n    const rule = entry.trim().toLowerCase();\n    if (!rule) return false;\n    if (rule.startsWith(\".\")) return file.name.toLowerCase().endsWith(rule);\n    if (rule.endsWith(\"/*\")) return file.type.startsWith(rule.slice(0, -1));\n    return file.type.toLowerCase() === rule;\n  });\n};\n\nconst createId = () =>\n  typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n    ? crypto.randomUUID()\n    : `${Date.now()}-${Math.random().toString(36).slice(2)}`;\n\nfunction UploadIcon({\n  open,\n  compact = false,\n  accent,\n}: {\n  open: boolean;\n  compact?: boolean;\n  accent: string;\n}) {\n  const iconRef = React.useRef<SVGSVGElement>(null);\n  const flapRef = React.useRef<SVGPathElement>(null);\n  const mouthRef = React.useRef<SVGPathElement>(null);\n  const shadowRef = React.useRef<SVGGElement>(null);\n\n  useGSAP(\n    () => {\n      const flap = flapRef.current;\n      const mouth = mouthRef.current;\n      const shadow = shadowRef.current;\n      if (!flap || !mouth || !shadow) return;\n\n      const reduceMotion = window.matchMedia(\n        \"(prefers-reduced-motion: reduce)\",\n      ).matches;\n      const duration = reduceMotion ? 0 : open ? 0.34 : 0.3;\n\n      gsap.to(flap, {\n        y: open ? 18 : 0,\n        scaleY: open ? 0.58 : 1,\n        transformOrigin: \"50% 100%\",\n        duration,\n        ease: open ? \"back.out(1.7)\" : \"power3.inOut\",\n        overwrite: \"auto\",\n      });\n      gsap.to(mouth, {\n        opacity: open ? 1 : 0,\n        y: open ? 0 : 5,\n        duration: reduceMotion ? 0 : open ? 0.18 : 0.26,\n        ease: \"power2.inOut\",\n        overwrite: \"auto\",\n      });\n      gsap.to(shadow, {\n        x: open ? 1 : 0,\n        y: open ? 2 : 0,\n        duration,\n        ease: \"power3.inOut\",\n        overwrite: \"auto\",\n      });\n    },\n    { scope: iconRef, dependencies: [open] },\n  );\n\n  return (\n    <svg\n      ref={iconRef}\n      viewBox=\"0 0 128 104\"\n      aria-hidden=\"true\"\n      className={cn(compact ? \"h-10 w-14\" : \"h-20 w-28\", \"overflow-visible\")}\n      fill=\"none\"\n    >\n      <g ref={shadowRef} transform=\"translate(4 5)\" fill=\"#737373\" opacity=\"0.72\">\n        <path d=\"M18 26h34l10 11h48v50H18z\" />\n        <path d=\"M14 43h100l-7 46H21z\" />\n      </g>\n      <path\n        d=\"M18 26h34l10 11h48v50H18z\"\n        fill=\"#050505\"\n        stroke=\"white\"\n        strokeWidth=\"1.5\"\n        strokeLinejoin=\"round\"\n      />\n      <path\n        ref={mouthRef}\n        d=\"M22 45h84l-6 12H28z\"\n        fill={accent}\n        stroke=\"white\"\n        strokeWidth=\"1.25\"\n        opacity=\"0\"\n        style={{ filter: `drop-shadow(0 0 8px ${accent})` }}\n      />\n      <path\n        ref={flapRef}\n        d=\"M14 43h100l-7 46H21z\"\n        fill=\"#050505\"\n        stroke=\"white\"\n        strokeWidth=\"1.5\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction FileRow({\n  item,\n  accent,\n  onCancel,\n  onRetry,\n  onRemove,\n}: {\n  item: FileUploadItem;\n  accent: string;\n  onCancel: () => void;\n  onRetry: () => void;\n  onRemove: () => void;\n}) {\n  const uploading = item.status === \"uploading\" || item.status === \"queued\";\n\n  return (\n    <li className=\"rounded-xl border border-border/80 bg-background/80 p-2.5 dark:border-white/10 dark:bg-black/20\">\n      <div className=\"flex items-center gap-2.5\">\n        <div\n          className={cn(\n            \"size-2 shrink-0 rounded-full\",\n            uploading && \"animate-pulse\",\n            item.status === \"success\" && \"bg-emerald-500\",\n            (item.status === \"error\" || item.status === \"cancelled\") && \"bg-red-500\",\n          )}\n          style={uploading ? { backgroundColor: accent } : undefined}\n        />\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"truncate text-sm font-medium text-foreground\">{item.file.name}</p>\n          <p className=\"text-[11px] text-muted-foreground\">\n            {formatBytes(item.file.size)}\n            {item.status === \"success\" && \" · Uploaded\"}\n            {item.status === \"cancelled\" && \" · Cancelled\"}\n          </p>\n        </div>\n        <span className=\"shrink-0 text-xs font-medium tabular-nums text-muted-foreground\">\n          {item.status === \"success\" ? \"100%\" : `${Math.round(item.progress)}%`}\n        </span>\n        {uploading && <Button type=\"button\" variant=\"ghost\" size=\"sm\" className=\"h-7 px-2.5\" onClick={onCancel}>Cancel</Button>}\n        {(item.status === \"error\" || item.status === \"cancelled\") && item.retryable && (\n          <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"h-7 px-2.5\" onClick={onRetry}>Retry</Button>\n        )}\n        {!uploading && <Button type=\"button\" variant=\"ghost\" size=\"sm\" className=\"h-7 px-2.5\" onClick={onRemove}>Remove</Button>}\n      </div>\n      <div\n        role=\"progressbar\"\n        aria-label={`Upload progress for ${item.file.name}`}\n        aria-valuemin={0}\n        aria-valuemax={100}\n        aria-valuenow={Math.round(item.progress)}\n        className=\"mt-2 h-1 overflow-hidden rounded-full bg-muted\"\n      >\n        <div\n          className={cn(\n            \"h-full rounded-full transition-[width,background-color] duration-200\",\n            item.status === \"success\" && \"bg-emerald-500\",\n            (item.status === \"error\" || item.status === \"cancelled\") && \"bg-red-500\",\n          )}\n          style={{\n            width: `${item.progress}%`,\n            backgroundColor: uploading ? accent : undefined,\n          }}\n        />\n      </div>\n      {item.error && <p className=\"mt-1.5 text-xs text-red-500 dark:text-red-400\">{item.error}</p>}\n    </li>\n  );\n}\n\nexport function FileUpload({\n  uploadFile,\n  accept,\n  multiple = true,\n  maxFiles = 5,\n  maxSize = 10 * 1024 * 1024,\n  layout = \"full\",\n  accent = \"#F97316\",\n  disabled = false,\n  className,\n  onFilesChange,\n}: FileUploadProps) {\n  const compact = layout === \"compact\";\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const controllersRef = React.useRef(new Map<string, AbortController>());\n  const mountedRef = React.useRef(true);\n  const dragDepthRef = React.useRef(0);\n  const [items, setItems] = React.useState<FileUploadItem[]>([]);\n  const [dragging, setDragging] = React.useState(false);\n  const [iconOpen, setIconOpen] = React.useState(false);\n\n  React.useEffect(() => {\n    onFilesChange?.(items);\n  }, [items, onFilesChange]);\n\n  React.useEffect(() => {\n    mountedRef.current = true;\n    const controllers = controllersRef.current;\n    return () => {\n      mountedRef.current = false;\n      controllers.forEach((controller) => controller.abort());\n    };\n  }, []);\n\n  const openIcon = React.useCallback(() => {\n    if (!disabled) setIconOpen(true);\n  }, [disabled]);\n\n  const updateItem = React.useCallback((id: string, patch: Partial<FileUploadItem>) => {\n    if (!mountedRef.current) return;\n    setItems((current) => current.map((item) => item.id === id ? { ...item, ...patch } : item));\n  }, []);\n\n  const startUpload = React.useCallback(async (item: FileUploadItem) => {\n    const controller = new AbortController();\n    controllersRef.current.set(item.id, controller);\n    updateItem(item.id, { status: \"uploading\", progress: 0, error: undefined, retryable: true });\n\n    try {\n      await uploadFile(\n        item.file,\n        (progress) => updateItem(item.id, { progress: Math.min(100, Math.max(0, progress)) }),\n        controller.signal,\n      );\n      if (!controller.signal.aborted) updateItem(item.id, { status: \"success\", progress: 100, error: undefined });\n    } catch (error) {\n      if (controller.signal.aborted) {\n        updateItem(item.id, { status: \"cancelled\", error: \"Upload cancelled.\", retryable: true });\n      } else {\n        updateItem(item.id, {\n          status: \"error\",\n          error: error instanceof Error ? error.message : \"Upload failed. Try again.\",\n          retryable: true,\n        });\n      }\n    } finally {\n      controllersRef.current.delete(item.id);\n    }\n  }, [updateItem, uploadFile]);\n\n  const addFiles = React.useCallback((files: File[]) => {\n    if (disabled || files.length === 0) return;\n    const available = Math.max(0, maxFiles - items.length);\n    const selected = multiple ? files : files.slice(0, 1);\n    const next = selected.map<FileUploadItem>((file, index) => {\n      const base = { id: createId(), file, progress: 0 };\n      if (index >= available) return { ...base, status: \"error\", error: `You can upload up to ${maxFiles} files.`, retryable: false };\n      if (file.size > maxSize) return { ...base, status: \"error\", error: `File exceeds the ${formatBytes(maxSize)} limit.`, retryable: false };\n      if (!acceptsFile(file, accept)) return { ...base, status: \"error\", error: \"This file type is not accepted.\", retryable: false };\n      return { ...base, status: \"queued\", retryable: true };\n    });\n\n    setItems((current) => [...current, ...next]);\n    next.filter((item) => item.status === \"queued\").forEach(startUpload);\n    if (inputRef.current) inputRef.current.value = \"\";\n  }, [accept, disabled, items.length, maxFiles, maxSize, multiple, startUpload]);\n\n  const cancel = (id: string) => controllersRef.current.get(id)?.abort();\n  const retry = (item: FileUploadItem) => startUpload(item);\n  const remove = (id: string) => {\n    controllersRef.current.get(id)?.abort();\n    controllersRef.current.delete(id);\n    setItems((current) => current.filter((item) => item.id !== id));\n  };\n  const openPicker = () => !disabled && inputRef.current?.click();\n\n  return (\n    <div className={cn(\"w-full max-w-xl\", className)}>\n      <input\n        ref={inputRef}\n        type=\"file\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        className=\"sr-only\"\n        onChange={(event) => addFiles(Array.from(event.target.files ?? []))}\n      />\n      <div\n        role=\"button\"\n        tabIndex={disabled ? -1 : 0}\n        aria-disabled={disabled}\n        aria-label=\"Choose files to upload\"\n        onClick={openPicker}\n        onKeyDown={(event) => {\n          if (event.key === \"Enter\" || event.key === \" \") {\n            event.preventDefault();\n            openPicker();\n          }\n        }}\n        onPointerEnter={openIcon}\n        onPointerLeave={() => setIconOpen(false)}\n        onFocus={openIcon}\n        onBlur={() => setIconOpen(false)}\n        onDragEnter={(event) => {\n          event.preventDefault();\n          if (disabled) return;\n          dragDepthRef.current += 1;\n          setDragging(true);\n          openIcon();\n        }}\n        onDragOver={(event) => event.preventDefault()}\n        onDragLeave={(event) => {\n          event.preventDefault();\n          dragDepthRef.current -= 1;\n          if (dragDepthRef.current <= 0) {\n            dragDepthRef.current = 0;\n            setDragging(false);\n            setIconOpen(false);\n          }\n        }}\n        onDrop={(event) => {\n          event.preventDefault();\n          dragDepthRef.current = 0;\n          setDragging(false);\n          setIconOpen(false);\n          addFiles(Array.from(event.dataTransfer.files));\n        }}\n        style={\n          iconOpen\n            ? {\n                borderColor: accent,\n                boxShadow: dragging ? `0 0 0 3px ${accent}22` : undefined,\n              }\n            : undefined\n        }\n        className={cn(\n          \"group rounded-2xl border-2 [border-style:dotted] border-border bg-card/55 text-center outline-none transition-[border-color,background-color,box-shadow,padding] duration-200\",\n          compact ? \"px-4 py-3\" : \"px-6 py-7\",\n          \"hover:border-foreground/45 hover:bg-card focus-visible:border-foreground/55 focus-visible:ring-2 focus-visible:ring-foreground/20\",\n          dragging && \"bg-foreground/3\",\n          disabled && \"pointer-events-none opacity-45\",\n        )}\n      >\n        <div className={cn(\n          \"mx-auto flex items-center\",\n          compact ? \"max-w-none flex-row gap-3\" : \"max-w-sm flex-col\",\n        )}>\n          <div className=\"opacity-55 saturate-50 transition-[opacity,filter] duration-200 group-hover:opacity-100 group-hover:saturate-100 group-focus-visible:opacity-100 group-focus-visible:saturate-100\">\n            <UploadIcon open={iconOpen} compact={compact} accent={accent} />\n          </div>\n          <div className={cn(compact && \"min-w-0 flex-1 text-left\")}>\n            <p className={cn(\"text-sm font-medium text-foreground\", !compact && \"mt-2\")}>\n              {items.length ? \"Add more files\" : compact ? \"Add files\" : \"Drop files here to upload\"}\n            </p>\n            <p className=\"mt-0.5 text-xs leading-5 text-muted-foreground\">\n              {maxFiles} files · {formatBytes(maxSize)} max\n            </p>\n          </div>\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            size=\"sm\"\n            className={cn(\"shrink-0\", !compact && \"mt-4\")}\n            disabled={disabled}\n            onClick={(event) => {\n              event.stopPropagation();\n              openPicker();\n            }}\n          >\n            {compact ? \"Browse\" : \"Browse files\"}\n          </Button>\n        </div>\n      </div>\n\n      {items.length > 0 && (\n        <ul className=\"mt-3 space-y-2\" aria-label=\"Selected files\">\n          {items.map((item) => (\n            <FileRow\n              key={item.id}\n              item={item}\n              accent={accent}\n              onCancel={() => cancel(item.id)}\n              onRetry={() => retry(item)}\n              onRemove={() => remove(item.id)}\n            />\n          ))}\n        </ul>\n      )}\n      <div className=\"sr-only\" aria-live=\"polite\">\n        {items.map((item) => `${item.file.name}: ${item.status}`).join(\". \")}\n      </div>\n    </div>\n  );\n}\n\nFileUpload.displayName = \"FileUpload\";\n\nexport default FileUpload;\n",
      "type": "registry:ui",
      "target": "@ui/file-upload.tsx"
    }
  ]
}
