{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload",
  "type": "registry:block",
  "title": "File upload",
  "description": "A complete file selection flow with constraints, progress, and error feedback.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/file-upload/file-upload.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport type { ChangeEvent, DragEvent } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n  type HTMLMotionProps,\n  type MotionProps,\n  type TargetAndTransition,\n  type Transition,\n} from \"motion/react\";\nimport { File, FileArchive, FileImage, FileText, RotateCw, UploadCloud, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type FileUploadItem = { id: string; file: File; error?: string };\n\nexport interface FileUploadProps {\n  accept?: string;\n  disabled?: boolean;\n  label?: string;\n  description?: string;\n  multiple?: boolean;\n  maxSize?: number;\n  value?: FileUploadItem[];\n  onChange?: (files: FileUploadItem[]) => void;\n  /** Uploads each accepted file. Report progress from 0 to 100, resolve when done, or reject to mark the file as failed. Removing a file aborts its signal. */\n  onUpload?: (file: File, options: { onProgress: (percent: number) => void; signal: AbortSignal }) => Promise<void>;\n}\n\ntype Upload = { status: \"uploading\" | \"done\" | \"failed\"; progress: number };\n\nconst enter: Transition = { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] };\nconst exitFast: Transition = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] };\nconst instant: Transition = { duration: 0 };\nconst textIn: TargetAndTransition = { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` };\nconst textOut: TargetAndTransition = {\n  opacity: 0,\n  y: \"-0.3em\",\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: exitFast,\n};\nconst iconIn: TargetAndTransition = { opacity: 0, scale: 0.6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst shown: TargetAndTransition = { opacity: 1, y: \"0em\", scale: 1, filter: \"blur(0px)\" };\nconst fadeOut: TargetAndTransition = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n/** Quick press, spring release. */\nconst rowButton =\n  \"grid size-[26px] flex-none cursor-pointer place-items-center rounded-button bg-transparent [transition:scale_var(--duration-spring)_var(--ease-spring),background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard)] hover:bg-surface hover:text-ink active:scale-96 active:duration-instant active:ease-standard motion-reduce:transition-none motion-reduce:active:scale-none\";\n\nfunction FileTypeIcon({ file }: { file: File }) {\n  const props = { size: 16, strokeWidth: 1.8, \"aria-hidden\": true } as const;\n  if (file.type.startsWith(\"image/\")) return <FileImage {...props} />;\n  if (file.type.startsWith(\"text/\")) return <FileText {...props} />;\n  if (file.type.includes(\"zip\") || file.name.endsWith(\".gz\")) return <FileArchive {...props} />;\n  return <File {...props} />;\n}\n\nfunction formatSize(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;\n  const mb = bytes / (1024 * 1024);\n  return `${mb >= 10 ? Math.round(mb) : Number(mb.toFixed(1))} MB`;\n}\n\n/** Outgoing copies are hidden from assistive tech while they fade, so live text reads only the current message. */\nfunction Swap(props: HTMLMotionProps<\"span\">) {\n  const present = useIsPresent();\n  return <motion.span {...props} aria-hidden={present ? props[\"aria-hidden\"] : true} />;\n}\n\nfunction FileRow({\n  item,\n  upload,\n  reduce,\n  onRemove,\n  onRetry,\n  removeRef,\n}: {\n  item: FileUploadItem;\n  upload?: Upload;\n  reduce: boolean | null;\n  onRemove: () => void;\n  onRetry: () => void;\n  removeRef: (node: HTMLButtonElement | null) => void;\n}) {\n  const status = upload?.status;\n  // A finished upload keeps its bar until the spring reaches the end, so 100% is seen before the label turns to Uploaded.\n  const [filled, setFilled] = useState(status !== \"uploading\");\n  const [seen, setSeen] = useState(status);\n  if (seen !== status) {\n    setSeen(status);\n    if (status === \"uploading\") setFilled(false);\n  }\n  const shownStatus = status === \"done\" && !filled && !reduce ? \"uploading\" : status;\n  const phase = item.error ? \"invalid\" : (shownStatus ?? \"ready\");\n  // One spring drives the bar and the counted percentage, so both always agree.\n  const progress = useMotionValue(upload?.progress ?? 0);\n  // The fill slides in from the left instead of scaling, so its rounded end keeps its shape at every value.\n  const x = useTransform(progress, (latest) => `${Math.min(Math.max(latest, 0), 100) - 100}%`);\n  const percent = useTransform(progress, (latest) => `${Math.round(Math.min(Math.max(latest, 0), 100))}%`);\n  const target = status === \"done\" ? 100 : (upload?.progress ?? 0);\n  useEffect(() => {\n    // Every upload starts from an empty bar, even a retry that failed halfway.\n    if (reduce || (status === \"uploading\" && target === 0)) {\n      progress.jump(target);\n      return;\n    }\n    const controls = animate(progress, target, {\n      ...motionTokens.spring.smooth,\n      onComplete: status === \"done\" ? () => setFilled(true) : undefined,\n    });\n    return () => controls.stop();\n  }, [status, target, progress, reduce]);\n  // The label turns as soon as the count reads 100%, without waiting out the spring's last fraction of a pixel.\n  useMotionValueEvent(progress, \"change\", (latest) => {\n    if (status === \"done\" && latest >= 99.5) setFilled(true);\n  });\n  const swap: MotionProps = {\n    initial: reduce ? { opacity: 0 } : textIn,\n    animate: shown,\n    exit: reduce ? fadeOut : textOut,\n    transition: reduce ? instant : enter,\n  };\n  return (\n    <div className=\"mt-1.5 flex min-w-0 items-center gap-3 rounded-control border border-line-soft bg-hover py-2.5 pr-2.5 pl-3\">\n      <span className=\"grid size-6 flex-none place-items-center text-ink-2\">\n        <FileTypeIcon file={item.file} />\n      </span>\n      <span className=\"grid min-w-0 flex-1 gap-0.5\">\n        <strong className=\"truncate text-sm leading-body font-medium\" title={item.file.name}>\n          {item.file.name}\n        </strong>\n        <span className=\"flex min-w-0 text-xs leading-body whitespace-nowrap text-ink-3 tabular-nums\">\n          {formatSize(item.file.size)}\n          {shownStatus ? (\n            <>\n              <span className=\"px-[5px]\" aria-hidden=\"true\">\n                ·\n              </span>\n              <span className=\"relative inline-block\">\n                <AnimatePresence mode=\"popLayout\" initial={false}>\n                  <Swap key={shownStatus} className={cn(\"block\", shownStatus === \"failed\" && \"text-red\")} {...swap}>\n                    {shownStatus === \"uploading\" ? (\n                      <>\n                        Uploading <motion.span className=\"inline-block min-w-[4ch]\">{percent}</motion.span>\n                      </>\n                    ) : shownStatus === \"done\" ? (\n                      \"Uploaded\"\n                    ) : (\n                      \"Upload failed\"\n                    )}\n                  </Swap>\n                </AnimatePresence>\n              </span>\n            </>\n          ) : null}\n        </span>\n        {item.error && <span className=\"text-xs leading-body text-red\">{item.error}</span>}\n        {/* The bar fills on a spring, then folds away once the file lands. */}\n        <AnimatePresence initial={false}>\n          {shownStatus === \"uploading\" && (\n            <motion.span\n              key=\"bar\"\n              className=\"block text-xs leading-body text-ink-3\"\n              initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={\n                reduce\n                  ? fadeOut\n                  : {\n                      height: 0,\n                      opacity: 0,\n                      transition: {\n                        height: { ...motionTokens.spring.smooth, delay: 0.24 },\n                        opacity: { ...exitFast, delay: 0.24 },\n                      },\n                    }\n              }\n              transition={reduce ? instant : { height: motionTokens.spring.smooth, opacity: enter }}\n            >\n              <span className=\"mt-[5px] mb-px block h-[3px] overflow-hidden rounded-[99px] bg-line\">\n                <motion.span className=\"block size-full rounded-[inherit] bg-accent\" style={{ x }} />\n              </span>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </span>\n      <span className=\"relative grid size-[26px] flex-none place-items-center\">\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {(phase === \"ready\" || phase === \"done\") && (\n            <motion.span\n              key=\"ready\"\n              className=\"grid size-[22px] place-items-center text-green\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={fadeOut}\n              transition={reduce ? instant : motionTokens.spring.snappy}\n            >\n              <svg\n                width={14}\n                height={14}\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={2}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                aria-hidden=\"true\"\n              >\n                <motion.path\n                  d=\"M4 12.5l5 5L20 6.5\"\n                  initial={reduce ? false : { pathLength: 0 }}\n                  animate={{ pathLength: 1 }}\n                  transition={{ ...enter, delay: 0.08 }}\n                />\n              </svg>\n              <span className=\"sr-only\">{phase === \"done\" ? \"Uploaded\" : \"Ready\"}</span>\n            </motion.span>\n          )}\n          {/* The wrapper carries the entrance, so the button's own press scale never competes with it. */}\n          {phase === \"failed\" && (\n            <motion.span\n              key=\"retry\"\n              className=\"grid place-items-center\"\n              initial={reduce ? { opacity: 0 } : iconIn}\n              animate={shown}\n              exit={reduce ? fadeOut : { ...iconIn, transition: exitFast }}\n              transition={reduce ? instant : motionTokens.spring.snappy}\n            >\n              <button\n                type=\"button\"\n                className={cn(rowButton, \"text-ink-2\")}\n                aria-label={`Retry ${item.file.name}`}\n                onClick={onRetry}\n              >\n                <RotateCw size={14} strokeWidth={1.8} aria-hidden=\"true\" />\n              </button>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </span>\n      <button\n        ref={removeRef}\n        type=\"button\"\n        className={cn(rowButton, \"text-ink-3\")}\n        aria-label={`Remove ${item.file.name}`}\n        onClick={onRemove}\n      >\n        <X size={15} aria-hidden=\"true\" />\n      </button>\n    </div>\n  );\n}\n\nexport function FileUpload({\n  accept,\n  disabled = false,\n  label = \"Upload files\",\n  description = \"Drop files here or browse from your device.\",\n  multiple = true,\n  maxSize,\n  value,\n  onChange,\n  onUpload,\n}: FileUploadProps) {\n  const inputId = useId();\n  const reduce = useReducedMotion();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const dropzoneRef = useRef<HTMLButtonElement>(null);\n  const [internalFiles, setInternalFiles] = useState<FileUploadItem[]>([]);\n  const [dragging, setDragging] = useState(false);\n  const [status, setStatus] = useState(\"\");\n  const [uploads, setUploads] = useState<Record<string, Upload>>({});\n  const dragDepth = useRef(0);\n  const nextId = useRef(0);\n  const controllers = useRef(new Map<string, AbortController>());\n  const removeRefs = useRef(new Map<string, HTMLButtonElement>());\n  const files = value ?? internalFiles;\n\n  useEffect(() => {\n    const active = controllers.current;\n    return () => active.forEach((controller) => controller.abort());\n  }, []);\n\n  function update(next: FileUploadItem[]) {\n    if (value === undefined) setInternalFiles(next);\n    onChange?.(next);\n  }\n\n  function startUpload(item: FileUploadItem) {\n    if (!onUpload) return;\n    controllers.current.get(item.id)?.abort();\n    const controller = new AbortController();\n    controllers.current.set(item.id, controller);\n    const set = (next: (current?: Upload) => Upload) =>\n      setUploads((current) => ({ ...current, [item.id]: next(current[item.id]) }));\n    set(() => ({ status: \"uploading\", progress: 0 }));\n    onUpload(item.file, {\n      signal: controller.signal,\n      onProgress: (percent) => {\n        if (!controller.signal.aborted)\n          set((current) => ({\n            status: \"uploading\",\n            progress: Math.min(Math.max(percent, current?.progress ?? 0), 100),\n          }));\n      },\n    })\n      .then(\n        () => {\n          if (controller.signal.aborted) return;\n          set(() => ({ status: \"done\", progress: 100 }));\n          setStatus(`${item.file.name} uploaded.`);\n        },\n        () => {\n          if (controller.signal.aborted) return;\n          set((current) => ({ status: \"failed\", progress: current?.progress ?? 0 }));\n          setStatus(`${item.file.name} could not be uploaded.`);\n        },\n      )\n      .finally(() => {\n        if (controllers.current.get(item.id) === controller) controllers.current.delete(item.id);\n      });\n  }\n\n  function addFiles(fileList: FileList | File[]) {\n    const incoming = Array.from(fileList).slice(0, multiple ? undefined : 1);\n    const acceptedTypes =\n      accept\n        ?.split(\",\")\n        .map((type) => type.trim().toLowerCase())\n        .filter(Boolean) ?? [];\n    const matchesAccept = (file: File) =>\n      acceptedTypes.length === 0 ||\n      acceptedTypes.some((type) =>\n        type.startsWith(\".\")\n          ? file.name.toLowerCase().endsWith(type)\n          : type.endsWith(\"/*\")\n            ? file.type.startsWith(type.slice(0, -1))\n            : file.type === type,\n      );\n    const accepted = incoming.map((file) => ({\n      id: `${file.name}-${file.lastModified}-${nextId.current++}`,\n      file,\n      error: !matchesAccept(file)\n        ? \"This file type is not accepted.\"\n        : maxSize && file.size > maxSize\n          ? `File is larger than ${formatSize(maxSize)}.`\n          : undefined,\n    }));\n    if (!multiple) controllers.current.forEach((controller) => controller.abort());\n    update(multiple ? [...files, ...accepted] : accepted);\n    const invalidCount = accepted.filter((item) => item.error).length;\n    const validCount = accepted.length - invalidCount;\n    const added = `${validCount} file${validCount === 1 ? \"\" : \"s\"} added.`;\n    const attention = `${invalidCount} file${invalidCount === 1 ? \" needs\" : \"s need\"} attention.`;\n    setStatus(!invalidCount ? added : validCount ? `${added} ${attention}` : attention);\n    accepted.filter((item) => !item.error).forEach(startUpload);\n  }\n\n  function removeFile(item: FileUploadItem) {\n    controllers.current.get(item.id)?.abort();\n    controllers.current.delete(item.id);\n    // Keep keyboard focus in the list: the next row's remove button, else the previous one, else the dropzone.\n    const index = files.findIndex((file) => file.id === item.id);\n    const neighbor = files[index + 1] ?? files[index - 1];\n    update(files.filter((file) => file.id !== item.id));\n    setStatus(`${item.file.name} removed.`);\n    requestAnimationFrame(() => (neighbor ? removeRefs.current.get(neighbor.id) : dropzoneRef.current)?.focus());\n  }\n\n  function handleInput(event: ChangeEvent<HTMLInputElement>) {\n    if (event.target.files) addFiles(event.target.files);\n    event.target.value = \"\";\n  }\n\n  function handleDrop(event: DragEvent<HTMLButtonElement>) {\n    event.preventDefault();\n    dragDepth.current = 0;\n    setDragging(false);\n    if (!disabled && event.dataTransfer.files.length) addFiles(event.dataTransfer.files);\n  }\n\n  function handleDragEnter(event: DragEvent<HTMLButtonElement>) {\n    event.preventDefault();\n    if (!disabled) {\n      dragDepth.current += 1;\n      setDragging(true);\n    }\n  }\n\n  function handleDragLeave(event: DragEvent<HTMLButtonElement>) {\n    event.preventDefault();\n    dragDepth.current = Math.max(0, dragDepth.current - 1);\n    if (dragDepth.current === 0) setDragging(false);\n  }\n\n  const height: Transition = reduce ? instant : { height: motionTokens.spring.smooth, opacity: enter };\n  return (\n    <div className=\"grid w-full min-w-0 text-ink\">\n      <input\n        ref={inputRef}\n        id={inputId}\n        className=\"sr-only\"\n        type=\"file\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        tabIndex={-1}\n        aria-hidden=\"true\"\n        onChange={handleInput}\n      />\n      {/* A full-width drop target: press feedback is color only, never a scale. */}\n      <button\n        ref={dropzoneRef}\n        type=\"button\"\n        className={cn(\n          \"flex min-h-[92px] w-full cursor-pointer items-center gap-3 rounded-panel border border-dashed border-line-strong bg-surface p-4 text-left [transition:border-color_var(--duration-standard)_var(--ease-standard),background-color_var(--duration-standard)_var(--ease-standard)] motion-reduce:transition-none\",\n          dragging && \"border-accent bg-accent-tint\",\n          disabled\n            ? \"cursor-not-allowed opacity-52\"\n            : \"hover:border-accent hover:bg-accent-tint active:border-accent active:bg-accent-tint\",\n        )}\n        tabIndex={disabled ? -1 : 0}\n        aria-disabled={disabled}\n        aria-describedby={`${inputId}-description`}\n        onClick={() => !disabled && inputRef.current?.click()}\n        onDragEnter={handleDragEnter}\n        onDragOver={(event) => event.preventDefault()}\n        onDragLeave={handleDragLeave}\n        onDrop={handleDrop}\n      >\n        {/* The cloud lifts toward the file while it hovers over the drop target. */}\n        <span\n          className={cn(\n            \"grid size-6 flex-none place-items-center text-accent-ink transition-transform duration-spring ease-spring motion-reduce:transition-none\",\n            dragging && \"-translate-y-[3px] motion-reduce:translate-none\",\n          )}\n        >\n          <UploadCloud size={20} strokeWidth={1.8} aria-hidden=\"true\" />\n        </span>\n        {/* While a file hovers over the target the label says what releasing it will do. */}\n        <span className=\"grid min-w-0 flex-1 gap-[3px]\">\n          <strong className=\"relative block min-w-0 text-sm leading-body font-medium\">\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <Swap\n                key={dragging ? \"drop\" : \"idle\"}\n                className=\"block\"\n                initial={reduce ? { opacity: 0 } : textIn}\n                animate={shown}\n                exit={reduce ? fadeOut : textOut}\n                transition={reduce ? instant : enter}\n              >\n                {dragging ? `Drop to add ${multiple ? \"files\" : \"a file\"}` : label}\n              </Swap>\n            </AnimatePresence>\n          </strong>\n          <span id={`${inputId}-description`} className=\"text-xs leading-body [overflow-wrap:anywhere] text-ink-3\">\n            {description}\n          </span>\n        </span>\n        <span className=\"flex-none text-xs/[inherit] font-medium text-accent-ink\">Browse</span>\n      </button>\n      {/* The live region stays mounted; its frame opens on the first message and each new message rises in. */}\n      <motion.div\n        className=\"overflow-hidden\"\n        initial={false}\n        animate={{ height: status ? \"auto\" : 0 }}\n        transition={height}\n      >\n        <p className=\"relative m-0 min-h-4 pt-2 text-xs leading-body text-ink-3\" aria-live=\"polite\" aria-atomic=\"true\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {status ? (\n              <Swap\n                key={status}\n                className=\"block\"\n                initial={reduce ? { opacity: 0 } : textIn}\n                animate={shown}\n                exit={reduce ? fadeOut : textOut}\n                transition={reduce ? instant : enter}\n              >\n                {status}\n              </Swap>\n            ) : null}\n          </AnimatePresence>\n        </p>\n      </motion.div>\n      {/* Rows open and close their own height, so the list closes the gap when a file is removed. */}\n      <motion.ul\n        className=\"m-0 grid list-none p-0\"\n        aria-label=\"Selected files\"\n        aria-hidden={files.length === 0 ? true : undefined}\n        initial={false}\n        animate={{ paddingTop: files.length ? 6 : 0 }}\n        transition={reduce ? instant : motionTokens.spring.smooth}\n      >\n        <AnimatePresence initial={false}>\n          {files.map((item) => (\n            <motion.li\n              key={item.id}\n              className=\"overflow-hidden\"\n              initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n              animate={{ height: \"auto\", opacity: 1 }}\n              exit={\n                reduce\n                  ? fadeOut\n                  : { height: 0, opacity: 0, transition: { height: motionTokens.spring.smooth, opacity: exitFast } }\n              }\n              transition={height}\n            >\n              <FileRow\n                item={item}\n                upload={uploads[item.id]}\n                reduce={reduce}\n                onRemove={() => removeFile(item)}\n                onRetry={() => startUpload(item)}\n                removeRef={(node) => {\n                  if (node) removeRefs.current.set(item.id, node);\n                  else removeRefs.current.delete(item.id);\n                }}\n              />\n            </motion.li>\n          ))}\n        </AnimatePresence>\n      </motion.ul>\n    </div>\n  );\n}\n\nexport default FileUpload;\n",
      "type": "registry:component",
      "target": "@components/iris/file-upload/file-upload.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/file-upload",
  "meta": {
    "kind": "block",
    "group": "Inputs",
    "export": "FileUpload",
    "docs": "https://irisui.dev/components/file-upload"
  }
}
