{
  "slug": "file-upload",
  "name": "File Upload",
  "description": "Accessible drag-and-drop file uploader with an animated folder and per-file progress states.",
  "summary": "A dotted upload dropzone built around the native file input. Its muted black folder opens on hover or focus, while queued files expose progress, success, failure, cancellation, retry, and removal states.",
  "category": "inputs",
  "tags": [
    "upload",
    "file",
    "dropzone",
    "progress",
    "drag-and-drop",
    "gsap"
  ],
  "dependencies": [
    "@gsap/react",
    "gsap",
    "@radix-ui/react-slot",
    "class-variance-authority",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "button"
  ],
  "version": "1.0.0",
  "createdDate": "2026-09-29",
  "updatedDate": "2026-09-29",
  "interactive": true,
  "supportsColor": true,
  "highlights": [
    "Black folder with a fine white outline, grey offset shadow, and hinged hover opening",
    "Native picker plus drag-and-drop interaction with dotted border feedback",
    "Per-file upload progress, success, failure, cancellation, retry, and removal states",
    "File count, size, and accepted-type validation",
    "Reduced-motion support and keyboard-operable dropzone"
  ],
  "anatomy": [
    "FileUpload (selection, validation, and upload orchestration)",
    "UploadIcon (GSAP folder-opening choreography)",
    "FileRow (progress and file actions)",
    "Button (existing DevClub UI primitive)"
  ],
  "physics": {
    "engine": "GSAP state transition",
    "description": "The folder flap opens from its lower hinge with a restrained back ease, revealing the folder mouth, then closes when hover or focus leaves.",
    "parameters": [
      {
        "label": "Trigger",
        "value": "Hover, focus, or drag entry"
      },
      {
        "label": "Duration",
        "value": "340ms open / 300ms close"
      },
      {
        "label": "Settle",
        "value": "back.out(1.7)"
      }
    ]
  },
  "accessibility": {
    "role": "button, progressbar",
    "aria": "The dropzone is keyboard operable, each progress bar names its file, and status changes are announced through a polite aria-live region.",
    "reducedMotion": "The folder transitions immediately between open and closed states when prefers-reduced-motion is enabled.",
    "keyboard": [
      {
        "key": "Enter / Space",
        "description": "Opens the native file picker."
      },
      {
        "key": "Tab",
        "description": "Moves between the dropzone and available file actions."
      }
    ]
  },
  "guidelines": {
    "recommended": [
      "Attachment fields, media uploads, and import workflows"
    ],
    "bestPractices": [
      "Provide a real uploadFile adapter",
      "Keep accepted types and limits visible",
      "Surface server errors through rejected promises"
    ]
  },
  "props": [
    {
      "name": "uploadFile",
      "type": "(file, onProgress, signal) => Promise<void>",
      "description": "Runs the upload and reports progress.",
      "required": true
    },
    {
      "name": "accept",
      "type": "string",
      "description": "Native file accept filter."
    },
    {
      "name": "multiple",
      "type": "boolean",
      "defaultValue": "true",
      "description": "Allows selecting more than one file."
    },
    {
      "name": "maxFiles",
      "type": "number",
      "defaultValue": "5",
      "description": "Maximum number of files."
    },
    {
      "name": "maxSize",
      "type": "number",
      "defaultValue": "10485760",
      "description": "Maximum size per file in bytes."
    },
    {
      "name": "layout",
      "type": "\"full\" | \"compact\"",
      "defaultValue": "\"full\"",
      "description": "Controls only the upload dropzone layout; file state rows remain compact."
    },
    {
      "name": "accent",
      "type": "string",
      "defaultValue": "\"#F97316\"",
      "description": "Colors the active border, glowing folder infill, upload indicator, and progress bar."
    },
    {
      "name": "disabled",
      "type": "boolean",
      "defaultValue": "false",
      "description": "Disables selection and drag-and-drop."
    },
    {
      "name": "className",
      "type": "string",
      "description": "Adds classes to the uploader root."
    },
    {
      "name": "onFilesChange",
      "type": "(items: FileUploadItem[]) => void",
      "description": "Receives file state updates."
    }
  ],
  "usage": "\"use client\";\n\nimport { FileUpload } from \"@/components/ui/file-upload\";\n\nexport function FileUploadDemo() {\n  return (\n    <FileUpload\n      accept=\"image/*,.pdf\"\n      uploadFile={async (file, onProgress, signal) => {\n        const body = new FormData();\n        body.append(\"file\", file);\n        await fetch(\"/api/upload\", { method: \"POST\", body, signal });\n        onProgress(100);\n      }}\n    />\n  );\n}",
  "files": [
    {
      "name": "button.tsx",
      "path": "components/ui/button.tsx",
      "code": "\"use client\";\n\nimport * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-xs font-medium tracking-wide transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500 dark:focus-visible:ring-white/60 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0 cursor-pointer select-none ease-[cubic-bezier(0.16,1,0.3,1)]\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-zinc-200 active:scale-[0.97] shadow-xs\",\n        destructive:\n          \"bg-red-500/90 text-white hover:bg-red-500 active:scale-[0.97] shadow-xs\",\n        outline:\n          \"border border-zinc-200 bg-white text-zinc-800 hover:border-zinc-300 hover:bg-zinc-100 hover:text-zinc-950 dark:border-white/15 dark:bg-zinc-900/60 dark:text-zinc-300 dark:hover:border-white/30 dark:hover:bg-zinc-800 dark:hover:text-white active:scale-[0.97]\",\n        secondary:\n          \"bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 active:scale-[0.97]\",\n        ghost:\n          \"hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-white/10 dark:hover:text-white active:scale-[0.97]\",\n        link: \"text-zinc-900 dark:text-white underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2\",\n        sm: \"h-8 rounded-md px-3 text-xs\",\n        lg: \"h-10 rounded-lg px-6 text-sm\",\n        icon: \"h-9 w-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n);\n\nexport interface ButtonProps\n  extends\n    React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean;\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : \"button\";\n    return (\n      <Comp\n        className={cn(buttonVariants({ variant, size, className }))}\n        ref={ref}\n        {...props}\n      />\n    );\n  },\n);\nButton.displayName = \"Button\";\n\nexport { Button, buttonVariants };\nexport default Button;\n",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { cn } from \"@/lib/utils\";\n\nconst buttonVariants = cva(\n  \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-xs font-medium tracking-wide transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500 dark:focus-visible:ring-white/60 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0 cursor-pointer select-none ease-[cubic-bezier(0.16,1,0.3,1)]\",\n  {\n    variants: {\n      variant: {\n        default:\n          \"bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-zinc-200 active:scale-[0.97] shadow-xs\",\n        destructive:\n          \"bg-red-500/90 text-white hover:bg-red-500 active:scale-[0.97] shadow-xs\",\n        outline:\n          \"border border-zinc-200 bg-white text-zinc-800 hover:border-zinc-300 hover:bg-zinc-100 hover:text-zinc-950 dark:border-white/15 dark:bg-zinc-900/60 dark:text-zinc-300 dark:hover:border-white/30 dark:hover:bg-zinc-800 dark:hover:text-white active:scale-[0.97]\",\n        secondary:\n          \"bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 active:scale-[0.97]\",\n        ghost:\n          \"hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-white/10 dark:hover:text-white active:scale-[0.97]\",\n        link: \"text-zinc-900 dark:text-white underline-offset-4 hover:underline\",\n      },\n      size: {\n        default: \"h-9 px-4 py-2\",\n        sm: \"h-8 rounded-md px-3 text-xs\",\n        lg: \"h-10 rounded-lg px-6 text-sm\",\n        icon: \"h-9 w-9\",\n      },\n    },\n    defaultVariants: {\n      variant: \"default\",\n      size: \"default\",\n    },\n  },\n);\n\nexport interface ButtonProps\n  extends\n    React.ButtonHTMLAttributes<HTMLButtonElement>,\n    VariantProps<typeof buttonVariants> {\n  asChild?: boolean;\n}\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, asChild = false, ...props }, ref) => {\n    const Comp = asChild ? Slot : \"button\";\n    return (\n      <Comp\n        className={cn(buttonVariants({ variant, size, className }))}\n        ref={ref}\n        {...props}\n      />\n    );\n  },\n);\nButton.displayName = \"Button\";\n\nexport { Button, buttonVariants };\nexport default Button;\n",
      "type": "registry:ui",
      "target": "@ui/button.tsx"
    },
    {
      "name": "file-upload.tsx",
      "path": "registry/ui/file-upload.tsx",
      "code": "\"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",
      "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"
    }
  ],
  "type": "registry:ui",
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "title": "File Upload"
}
