{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-dropzone",
  "type": "registry:ui",
  "title": "File Dropzone",
  "description": "Drag-and-drop file dropzone with a fanned sheets illustration, drag state styling and animated file lifecycle rows.",
  "dependencies": [
    "motion",
    "lucide-react",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/file-dropzone.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type DragEvent,\n  type KeyboardEvent,\n} from \"react\";\nimport {\n  ArrowUp,\n  CircleAlert,\n  File as FileIcon,\n  FileArchive,\n  FileImage,\n  FilePlay,\n  FileText,\n  RotateCw,\n  X,\n} from \"lucide-react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useIsPresent,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n  type HTMLMotionProps,\n  type TargetAndTransition,\n  type Transition,\n} from \"motion/react\";\nimport { motionTokens } from \"@/lib/motion-tokens\";\nimport { cn } from \"@/lib/utils\";\n\nexport type FileDropzoneStatus = \"uploading\" | \"uploaded\" | \"failed\";\n\nexport type FileDropzoneItem = {\n  id: string;\n  name: string;\n  size: number;\n  status?: FileDropzoneStatus;\n  progress?: number;\n  error?: string;\n  retryable?: boolean;\n  file?: File;\n  preview?: string;\n};\n\nexport type FileDropzoneUpload = (\n  item: FileDropzoneItem,\n  options: { onProgress: (percent: number) => void; signal: AbortSignal },\n) => Promise<void>;\n\nexport type FileDropzoneProps = {\n  accept?: string;\n  multiple?: boolean;\n  maxFiles?: number;\n  onFilesChange?: (files: File[]) => void;\n  label?: string;\n  description?: string;\n  defaultItems?: FileDropzoneItem[];\n  onUpload?: FileDropzoneUpload;\n  maxSize?: number;\n  listPlacement?: \"below\" | \"inside\";\n  note?: string;\n  dropLabel?: string;\n  compactAt?: number;\n  className?: string;\n};\n\nconst MB = 1024 * 1024;\nconst enter: Transition = {\n  duration: motionTokens.duration.standard,\n  ease: [...motionTokens.ease.enter],\n};\nconst exitFast: Transition = {\n  duration: motionTokens.duration.fast,\n  ease: [...motionTokens.ease.standard],\n};\nconst instant: Transition = { duration: 0 };\nconst fade: Transition = { duration: motionTokens.duration.instant };\nconst collapse: Transition = {\n  type: \"spring\",\n  stiffness: 380,\n  damping: 30,\n  mass: 0.8,\n};\nconst textIn: TargetAndTransition = {\n  opacity: 0,\n  y: \"0.3em\",\n  filter: `blur(${motionTokens.blur.soft}px)`,\n};\nconst textOut: TargetAndTransition = {\n  opacity: 0,\n  y: \"-0.3em\",\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: exitFast,\n};\nconst shown: TargetAndTransition = {\n  opacity: 1,\n  y: \"0em\",\n  filter: \"blur(0px)\",\n};\nconst clamp = (value: number) => Math.min(Math.max(value, 0), 100);\nconst SHAKE = { x: [0, -7, 6, -4, 3, -1.5, 0] };\nconst shakeTransition: Transition = { duration: 0.42, ease: \"easeOut\" };\nconst land: Transition = {\n  type: \"spring\",\n  stiffness: 300,\n  damping: 22,\n  mass: 0.8,\n};\n\nexport function formatFileSize(bytes: number) {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < MB) return `${Math.round(bytes / 1024)} KB`;\n  const mb = bytes / MB;\n  return `${mb >= 10 ? Math.round(mb) : Number(mb.toFixed(1))} MB`;\n}\n\nfunction TypeIcon({ name }: { name: string }) {\n  const props = { size: 20, strokeWidth: 1.75, \"aria-hidden\": true } as const;\n  const extension = name.toLowerCase().split(\".\").pop() ?? \"\";\n  if (\n    [\"png\", \"jpg\", \"jpeg\", \"gif\", \"webp\", \"svg\", \"avif\", \"heic\"].includes(\n      extension,\n    )\n  ) {\n    return <FileImage {...props} />;\n  }\n  if ([\"mov\", \"mp4\", \"webm\", \"m4v\", \"avi\"].includes(extension)) {\n    return <FilePlay {...props} />;\n  }\n  if ([\"zip\", \"gz\", \"tar\", \"rar\", \"7z\"].includes(extension)) {\n    return <FileArchive {...props} />;\n  }\n  if ([\"pdf\", \"md\", \"txt\", \"doc\", \"docx\", \"rtf\"].includes(extension)) {\n    return <FileText {...props} />;\n  }\n  return <FileIcon {...props} />;\n}\n\nfunction Swap(props: HTMLMotionProps<\"span\">) {\n  const present = useIsPresent();\n  return (\n    <motion.span\n      {...props}\n      aria-hidden={present ? props[\"aria-hidden\"] : true}\n    />\n  );\n}\n\nfunction TextSwap({\n  text,\n  className,\n  reduce,\n}: {\n  text: string;\n  className?: string;\n  reduce: boolean;\n}) {\n  return (\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      <Swap\n        key={text}\n        className={className}\n        initial={reduce ? { opacity: 0 } : textIn}\n        animate={shown}\n        exit={reduce ? { opacity: 0, transition: fade } : textOut}\n        transition={reduce ? fade : enter}\n      >\n        {text}\n      </Swap>\n    </AnimatePresence>\n  );\n}\n\nfunction MotionText({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  const words = text.split(\" \");\n  return (\n    <>\n      <span className=\"sr-only\">{text}</span>\n      <span className=\"inline-flex flex-wrap gap-x-1\" aria-hidden=\"true\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {words.map((word, index) => (\n            <motion.span\n              key={`${index}:${word}`}\n              className=\"inline-block\"\n              initial={\n                reduced\n                  ? { opacity: 0 }\n                  : {\n                      opacity: 0,\n                      y: \"0.35em\",\n                      filter: `blur(${motionTokens.blur.soft}px)`,\n                    }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : {\n                      opacity: 0,\n                      y: \"-0.35em\",\n                      filter: `blur(${motionTokens.blur.subtle}px)`,\n                      transition: {\n                        duration: 0.14,\n                        ease: [...motionTokens.ease.standard],\n                      },\n                    }\n              }\n              transition={\n                reduced\n                  ? { duration: motionTokens.duration.instant }\n                  : {\n                      duration: motionTokens.duration.standard,\n                      ease: [...motionTokens.ease.enter],\n                    }\n              }\n            >\n              {index < words.length - 1 ? `${word} ` : word}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </>\n  );\n}\n\nfunction ErrorRow({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  const copyRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n\n  useEffect(() => {\n    const node = copyRef.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(([entry]) =>\n      setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight),\n    );\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  return (\n    <motion.div\n      className=\"overflow-hidden mt-2\"\n      initial={{ height: 0, opacity: 0 }}\n      animate={{ height, opacity: 1 }}\n      exit={{\n        height: 0,\n        opacity: 0,\n        transition: reduced\n          ? { duration: 0 }\n          : {\n              height: collapse,\n              opacity: { duration: motionTokens.duration.instant },\n            },\n      }}\n      transition={\n        reduced\n          ? { duration: 0 }\n          : {\n              height: {\n                type: \"spring\",\n                stiffness: 340,\n                damping: 28,\n                mass: 0.8,\n              },\n              opacity: { duration: motionTokens.duration.fast },\n            }\n      }\n    >\n      <motion.div\n        ref={copyRef}\n        className=\"flex items-center gap-2 px-3.5 py-2.5 rounded-xl text-xs font-medium text-rose-500 dark:text-rose-400 bg-rose-500/10 border border-rose-500/20\"\n        role=\"alert\"\n        initial={\n          reduced\n            ? false\n            : { y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }\n        }\n        animate={{ y: 0, filter: \"blur(0px)\" }}\n        transition={{\n          duration: reduced ? 0 : motionTokens.duration.standard,\n          ease: [...motionTokens.ease.enter],\n        }}\n      >\n        <CircleAlert\n          className=\"size-4 shrink-0\"\n          strokeWidth={2.25}\n          aria-hidden=\"true\"\n        />\n        <span className=\"flex-1 min-w-0\">\n          <MotionText text={text} />\n        </span>\n      </motion.div>\n    </motion.div>\n  );\n}\n\nfunction DrawnCheck({ reduce }: { reduce: boolean }) {\n  return (\n    <svg\n      className=\"size-3.5 shrink-0\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2.25}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n    >\n      <motion.path\n        d=\"M12 2.5a9.5 9.5 0 1 1 0 19a9.5 9.5 0 1 1 0-19\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{\n          duration: motionTokens.duration.considered * 0.7,\n          ease: [...motionTokens.ease.enter],\n        }}\n      />\n      <motion.path\n        d=\"m8.2 12.4 2.6 2.6 5-5.2\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ ...enter, delay: 0.16 }}\n      />\n    </svg>\n  );\n}\n\ntype RowProps = {\n  item: FileDropzoneItem;\n  reduce: boolean;\n  delay: number;\n  fresh: boolean;\n  canRetry: boolean;\n  onRemove: () => void;\n  onRetry: () => void;\n  removeRef: (node: HTMLButtonElement | null) => void;\n};\n\nfunction Thumb({ item }: { item: FileDropzoneItem }) {\n  const [broken, setBroken] = useState(false);\n  if (!item.preview || broken) {\n    return (\n      <span className=\"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-neutral-200/60 dark:bg-[#202023] text-neutral-600 dark:text-neutral-400 border border-neutral-300/40 dark:border-white/8\">\n        <TypeIcon name={item.name} />\n      </span>\n    );\n  }\n  return (\n    <span className=\"relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-neutral-200/60 dark:bg-[#202023] border border-neutral-300/40 dark:border-white/8\">\n      <img\n        src={item.preview}\n        alt=\"\"\n        width={40}\n        height={40}\n        decoding=\"async\"\n        className=\"h-full w-full object-cover\"\n        onError={() => setBroken(true)}\n      />\n    </span>\n  );\n}\n\nfunction FileRow({\n  item,\n  reduce,\n  delay,\n  fresh,\n  canRetry,\n  onRemove,\n  onRetry,\n  removeRef,\n}: RowProps) {\n  const { status } = item;\n  const rowRef = useRef<HTMLDivElement>(null);\n  const shakenFor = useRef<FileDropzoneStatus | \"new\" | undefined>(\n    fresh ? \"new\" : status,\n  );\n\n  useEffect(() => {\n    const previous = shakenFor.current;\n    if (status === previous) return;\n    shakenFor.current = status;\n    const node = rowRef.current;\n    if (status !== \"failed\" || reduce || !node) return;\n    animate(node, SHAKE, {\n      ...shakeTransition,\n      delay: previous === \"new\" ? delay + 0.3 : 0,\n    });\n  }, [status, reduce, delay]);\n\n  const progress = useMotionValue(\n    status === \"uploaded\" ? 100 : (item.progress ?? 0),\n  );\n  const x = useTransform(progress, (value) => `${clamp(value) - 100}%`);\n  const percent = useTransform(\n    progress,\n    (value) => `${Math.round(clamp(value))}%`,\n  );\n\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\n  const phase =\n    status === \"uploaded\" && !filled && !reduce ? \"uploading\" : status;\n  const target = status === \"uploaded\" ? 100 : (item.progress ?? 0);\n\n  useEffect(() => {\n    if (status !== \"uploading\" && status !== \"uploaded\") return;\n    if (reduce || (status === \"uploading\" && target === 0)) {\n      progress.jump(target);\n      return;\n    }\n    const controls = animate(progress, target, {\n      type: \"spring\",\n      stiffness: 280,\n      damping: 28,\n      onComplete: status === \"uploaded\" ? () => setFilled(true) : undefined,\n    });\n    return () => controls.stop();\n  }, [status, target, progress, reduce]);\n\n  useMotionValueEvent(progress, \"change\", (value) => {\n    if (status === \"uploaded\" && value >= 99.5) setFilled(true);\n  });\n\n  const failed = status === \"failed\";\n  const retry = failed && canRetry && item.retryable !== false;\n\n  return (\n    <motion.li\n      className=\"overflow-hidden list-none\"\n      initial={reduce ? { opacity: 0 } : { height: 0 }}\n      animate={{ height: \"auto\", opacity: 1 }}\n      exit={\n        reduce\n          ? { opacity: 0, transition: fade }\n          : {\n              height: 0,\n              opacity: 0,\n              transition: {\n                height: collapse,\n                opacity: { ...exitFast, delay: 0.04 },\n              },\n            }\n      }\n      transition={\n        reduce\n          ? fade\n          : {\n              height: {\n                type: \"spring\",\n                stiffness: 320,\n                damping: 28,\n                mass: 0.8,\n                delay,\n              },\n              opacity: fade,\n            }\n      }\n    >\n      <motion.div\n        ref={rowRef}\n        className={cn(\n          \"group relative flex items-center gap-3.5 p-2.5 pl-3 pr-3.5 rounded-2xl border transition-colors select-none\",\n          failed\n            ? \"bg-rose-500/5 dark:bg-rose-950/20 border-rose-500/30 dark:border-rose-500/30 text-rose-500 dark:text-rose-300\"\n            : \"bg-neutral-100/80 dark:bg-[#1a1a1c] border-neutral-200/80 dark:border-white/8 hover:border-neutral-300 dark:hover:border-white/[0.14]\",\n        )}\n        initial={\n          reduce\n            ? false\n            : {\n                opacity: 0,\n                y: -22,\n                scale: 0.94,\n                filter: `blur(${motionTokens.blur.soft}px)`,\n              }\n        }\n        animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n        exit={\n          reduce\n            ? undefined\n            : {\n                scale: 0.97,\n                filter: `blur(${motionTokens.blur.subtle}px)`,\n                transition: exitFast,\n              }\n        }\n        transition={\n          reduce\n            ? instant\n            : {\n                y: { ...land, delay },\n                scale: { ...land, delay },\n                opacity: { ...enter, delay },\n                filter: { ...enter, delay },\n              }\n        }\n      >\n        <Thumb item={item} />\n        <span className=\"flex flex-col min-w-0 flex-1 justify-center\">\n          <span\n            className=\"truncate text-sm font-medium text-neutral-900 dark:text-neutral-100 tracking-tight\"\n            title={item.name}\n          >\n            {item.name}\n          </span>\n          <span className=\"flex min-w-0 items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400 mt-0.5\">\n            <span className=\"shrink-0 whitespace-nowrap\">{formatFileSize(item.size)}</span>\n            {phase && (\n              <>\n                <span\n                  className=\"text-neutral-400 dark:text-neutral-600\"\n                  aria-hidden=\"true\"\n                >\n                  ·\n                </span>\n                <span className=\"inline-flex min-w-0 items-center\">\n                  <AnimatePresence mode=\"popLayout\" initial={false}>\n                    <Swap\n                      key={phase}\n                      className={cn(\n                        \"inline-flex min-w-0 items-center gap-1.5 font-medium\",\n                        phase === \"uploaded\" &&\n                          \"text-emerald-600 dark:text-emerald-400\",\n                        phase === \"failed\" &&\n                          \"text-rose-500 dark:text-rose-400\",\n                      )}\n                      initial={reduce ? { opacity: 0 } : textIn}\n                      animate={shown}\n                      exit={reduce ? { opacity: 0, transition: fade } : textOut}\n                      transition={reduce ? fade : enter}\n                    >\n                      {phase === \"uploading\" ? (\n                        <span className=\"text-neutral-500 dark:text-neutral-400\">\n                          Uploading{\" \"}\n                          <motion.span className=\"tabular-nums font-mono\">\n                            {percent}\n                          </motion.span>\n                        </span>\n                      ) : phase === \"uploaded\" ? (\n                        <>\n                          <DrawnCheck reduce={reduce} />\n                          <span>Uploaded</span>\n                        </>\n                      ) : (\n                        <>\n                          <CircleAlert\n                            className=\"size-3.5 shrink-0\"\n                            strokeWidth={2.25}\n                            aria-hidden=\"true\"\n                          />\n                          <span className=\"truncate\" title={item.error}>\n                            <span className=\"sr-only\">Failed: </span>\n                            {item.error || \"Upload failed\"}\n                          </span>\n                        </>\n                      )}\n                    </Swap>\n                  </AnimatePresence>\n                </span>\n              </>\n            )}\n          </span>\n          <AnimatePresence initial={false}>\n            {phase === \"uploading\" && (\n              <motion.span\n                key=\"bar\"\n                className=\"overflow-hidden block\"\n                initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                exit={\n                  reduce\n                    ? { opacity: 0, transition: fade }\n                    : {\n                        height: 0,\n                        opacity: 0,\n                        transition: {\n                          height: { ...collapse, delay: 0.22 },\n                          opacity: { ...exitFast, delay: 0.16 },\n                        },\n                      }\n                }\n                transition={\n                  reduce\n                    ? fade\n                    : {\n                        height: {\n                          type: \"spring\",\n                          stiffness: 340,\n                          damping: 28,\n                          mass: 0.8,\n                        },\n                        opacity: enter,\n                      }\n                }\n              >\n                <span\n                  className=\"relative mt-2 block h-1 w-full overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800\"\n                  role=\"progressbar\"\n                  aria-label={`Uploading ${item.name}`}\n                  aria-valuemin={0}\n                  aria-valuemax={100}\n                  aria-valuenow={Math.round(clamp(target))}\n                >\n                  <motion.span\n                    className=\"block h-full w-full rounded-full bg-neutral-900 dark:bg-white\"\n                    style={{ x }}\n                  />\n                </span>\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </span>\n        <span className=\"flex items-center gap-1.5 shrink-0 ml-2\">\n          <AnimatePresence initial={false}>\n            {retry && (\n              <motion.span\n                key=\"retry\"\n                className=\"overflow-hidden inline-flex\"\n                initial={reduce ? { opacity: 0 } : { width: 0, opacity: 0 }}\n                animate={{ width: \"auto\", opacity: 1 }}\n                exit={\n                  reduce\n                    ? { opacity: 0, transition: fade }\n                    : {\n                        width: 0,\n                        opacity: 0,\n                        transition: {\n                          width: collapse,\n                          opacity: { duration: motionTokens.duration.instant },\n                        },\n                      }\n                }\n                transition={\n                  reduce\n                    ? fade\n                    : {\n                        width: {\n                          type: \"spring\",\n                          stiffness: 340,\n                          damping: 28,\n                          mass: 0.8,\n                        },\n                        opacity: enter,\n                      }\n                }\n              >\n                <motion.span\n                  className=\"inline-flex\"\n                  initial={\n                    reduce\n                      ? false\n                      : {\n                          scale: 0.8,\n                          filter: `blur(${motionTokens.blur.subtle}px)`,\n                        }\n                  }\n                  animate={{ scale: 1, filter: \"blur(0px)\" }}\n                  transition={\n                    reduce\n                      ? instant\n                      : {\n                          type: \"spring\",\n                          stiffness: 360,\n                          damping: 24,\n                          mass: 0.8,\n                        }\n                  }\n                >\n                  <button\n                    type=\"button\"\n                    className=\"inline-flex items-center gap-1.5 px-3 py-1 max-sm:px-2 text-xs font-medium rounded-full bg-neutral-200/80 hover:bg-neutral-200 dark:bg-neutral-800/90 dark:hover:bg-neutral-700 text-neutral-800 dark:text-neutral-200 border border-neutral-300/80 dark:border-white/10 transition-colors cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-neutral-400\"\n                    onClick={onRetry}\n                    aria-label={`Retry ${item.name}`}\n                    title=\"Retry\"\n                  >\n                    <RotateCw size={12} strokeWidth={2.25} aria-hidden=\"true\" />\n                    <span className=\"max-sm:sr-only\">Retry</span>\n                  </button>\n                </motion.span>\n              </motion.span>\n            )}\n          </AnimatePresence>\n          <button\n            ref={removeRef}\n            type=\"button\"\n            className=\"p-1.5 rounded-lg text-neutral-400 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-white hover:bg-neutral-200/50 dark:hover:bg-white/10 transition-colors cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-neutral-400\"\n            onClick={onRemove}\n            aria-label={`Remove ${item.name}`}\n            title=\"Remove\"\n          >\n            <X size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n          </button>\n        </span>\n      </motion.div>\n    </motion.li>\n  );\n}\n\nexport function FileDropzone({\n  accept,\n  multiple = true,\n  maxFiles = 5,\n  onFilesChange,\n  label = \"Add files\",\n  description = \"Drop files here or choose from your device\",\n  defaultItems,\n  onUpload,\n  maxSize,\n  listPlacement = \"below\",\n  note,\n  dropLabel,\n  compactAt,\n  className,\n}: FileDropzoneProps) {\n  const [items, setItems] = useState<FileDropzoneItem[]>(\n    () => defaultItems ?? [],\n  );\n  const [dragging, setDragging] = useState(false);\n  const [error, setError] = useState(\"\");\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [batchStart, setBatchStart] = useState(0);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const dropRef = useRef<HTMLButtonElement>(null);\n  const zoneRef = useRef<HTMLDivElement>(null);\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 descriptionId = useId();\n  const noteId = useId();\n  const reduce = !!useReducedMotion();\n  const inside = listPlacement === \"inside\";\n  const compact = compactAt !== undefined && items.length >= compactAt;\n  const [freshIds, setFreshIds] = useState<ReadonlySet<string>>(\n    () => new Set(),\n  );\n  const ownedPreviews = useRef(new Map<string, string>());\n  const [hovered, setHovered] = useState(false);\n  const [focusWithin, setFocusWithin] = useState(false);\n\n  useEffect(() => {\n    const active = controllers.current;\n    const previews = ownedPreviews.current;\n    return () => {\n      active.forEach((controller) => controller.abort());\n      previews.forEach((url) => URL.revokeObjectURL(url));\n    };\n  }, []);\n\n  const shakeZone = () => {\n    if (!reduce && zoneRef.current)\n      animate(zoneRef.current, SHAKE, shakeTransition);\n  };\n\n  const filesOf = (list: FileDropzoneItem[]) =>\n    list.flatMap((item) => (item.file ? [item.file] : []));\n  const patch = (\n    id: string,\n    next: (item: FileDropzoneItem) => FileDropzoneItem,\n  ) =>\n    setItems((current) =>\n      current.map((item) => (item.id === id ? next(item) : item)),\n    );\n\n  function startUpload(item: FileDropzoneItem) {\n    if (!onUpload) return;\n    controllers.current.get(item.id)?.abort();\n    const controller = new AbortController();\n    controllers.current.set(item.id, controller);\n    patch(item.id, (current) => ({\n      ...current,\n      status: \"uploading\",\n      progress: 0,\n      error: undefined,\n    }));\n    onUpload(\n      { ...item, status: \"uploading\", progress: 0, error: undefined },\n      {\n        signal: controller.signal,\n        onProgress: (percent) => {\n          if (!controller.signal.aborted) {\n            patch(item.id, (current) =>\n              current.status === \"uploading\"\n                ? {\n                    ...current,\n                    progress: Math.max(current.progress ?? 0, clamp(percent)),\n                  }\n                : current,\n            );\n          }\n        },\n      },\n    )\n      .then(\n        () => {\n          if (controller.signal.aborted) return;\n          patch(item.id, (current) => ({\n            ...current,\n            status: \"uploaded\",\n            progress: 100,\n          }));\n          setAnnouncement(`${item.name} uploaded`);\n        },\n        (reason: unknown) => {\n          if (controller.signal.aborted) return;\n          const message =\n            reason instanceof Error && reason.message\n              ? reason.message\n              : \"Upload failed\";\n          patch(item.id, (current) => ({\n            ...current,\n            status: \"failed\",\n            error: message,\n            retryable: true,\n          }));\n          setAnnouncement(`${item.name} failed. ${message}`);\n        },\n      )\n      .finally(() => {\n        if (controllers.current.get(item.id) === controller) {\n          controllers.current.delete(item.id);\n        }\n      });\n  }\n\n  function addFiles(incoming: FileList | File[]) {\n    const list = Array.from(incoming);\n    const accepted =\n      accept\n        ?.split(\",\")\n        .map((value) => value.trim().toLowerCase())\n        .filter(Boolean) ?? [];\n    const matching = list.filter(\n      (file) =>\n        accepted.length === 0 ||\n        accepted.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    );\n    const kept = multiple ? items : [];\n    const same = (a: File, b: File) =>\n      a.name === b.name &&\n      a.size === b.size &&\n      a.lastModified === b.lastModified;\n    const fresh = matching.filter(\n      (file, index) =>\n        matching.findIndex((other) => same(other, file)) === index &&\n        !kept.some((item) => item.file && same(item.file, file)),\n    );\n    const room = multiple ? Math.max(0, maxFiles - kept.length) : 1;\n    const added = fresh.slice(0, room).map((file): FileDropzoneItem => {\n      const tooLarge = maxSize !== undefined && file.size > maxSize;\n      const id = `${file.name}-${file.size}-${file.lastModified}-${nextId.current++}`;\n      const preview =\n        file.type.startsWith(\"image/\") &&\n        typeof URL.createObjectURL === \"function\"\n          ? URL.createObjectURL(file)\n          : undefined;\n      if (preview) ownedPreviews.current.set(id, preview);\n      return {\n        id,\n        name: file.name,\n        size: file.size,\n        file,\n        preview,\n        status: tooLarge ? \"failed\" : onUpload ? \"uploading\" : undefined,\n        progress: 0,\n        error: tooLarge\n          ? `File is larger than ${formatFileSize(maxSize)}`\n          : undefined,\n        retryable: !tooLarge,\n      };\n    });\n    const rejected = matching.length !== list.length;\n    const overflow = fresh.length > added.length;\n    setError(\n      rejected\n        ? list.length - matching.length === 1 && list.length === 1\n          ? `${list[0].name} is not an accepted file type.`\n          : \"Some files were not added because their type is not accepted.\"\n        : overflow\n          ? `You can add up to ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}.`\n          : \"\",\n    );\n    if (rejected || overflow) shakeZone();\n    if (!added.length) return;\n    if (!multiple) {\n      controllers.current.forEach((controller) => controller.abort());\n      controllers.current.clear();\n    }\n    const next = [...kept, ...added];\n    setFreshIds(\n      (current) => new Set([...current, ...added.map((item) => item.id)]),\n    );\n    setBatchStart(kept.length);\n    setItems((current) => (multiple ? [...current, ...added] : added));\n    onFilesChange?.(filesOf(next));\n    const failed = added.filter((item) => item.status === \"failed\");\n    setAnnouncement(\n      `${added.length} ${added.length === 1 ? \"file\" : \"files\"} added.${\n        failed.length\n          ? ` ${failed.map((item) => `${item.name}: ${item.error}`).join(\". \")}.`\n          : \"\"\n      }`,\n    );\n    added.filter((item) => item.status === \"uploading\").forEach(startUpload);\n  }\n\n  function removeItem(target: FileDropzoneItem) {\n    controllers.current.get(target.id)?.abort();\n    controllers.current.delete(target.id);\n    const index = items.findIndex((item) => item.id === target.id);\n    const neighbor = items[index + 1] ?? items[index - 1];\n    const next = items.filter((item) => item.id !== target.id);\n    const preview = ownedPreviews.current.get(target.id);\n    if (preview) {\n      ownedPreviews.current.delete(target.id);\n      window.setTimeout(() => URL.revokeObjectURL(preview), 600);\n    }\n    setItems((current) => current.filter((item) => item.id !== target.id));\n    setError(\"\");\n    setAnnouncement(`${target.name} removed`);\n    if (target.file) onFilesChange?.(filesOf(next));\n    requestAnimationFrame(() =>\n      (neighbor\n        ? removeRefs.current.get(neighbor.id)\n        : dropRef.current\n      )?.focus(),\n    );\n  }\n\n  function retryItem(item: FileDropzoneItem) {\n    setAnnouncement(`Retrying ${item.name}`);\n    startUpload(item);\n    requestAnimationFrame(() => removeRefs.current.get(item.id)?.focus());\n  }\n\n  function moveFocus(event: KeyboardEvent<HTMLUListElement>) {\n    if (event.key === \"Delete\" || event.key === \"Backspace\") {\n      const row = (event.target as HTMLElement).closest<HTMLElement>(`li`);\n      const remove = row?.querySelector<HTMLButtonElement>(\n        `button[title=\"Remove\"]`,\n      );\n      if (remove) {\n        event.preventDefault();\n        remove.click();\n      }\n      return;\n    }\n    if (event.key !== \"ArrowDown\" && event.key !== \"ArrowUp\") return;\n    const button = (event.target as HTMLElement).closest(\"button\");\n    const rows = Array.from(\n      event.currentTarget.querySelectorAll<HTMLElement>(`li`),\n    );\n    const row = button?.closest<HTMLElement>(`li`);\n    if (!button || !row) return;\n    const nextRow =\n      rows[rows.indexOf(row) + (event.key === \"ArrowDown\" ? 1 : -1)];\n    if (!nextRow) return;\n    event.preventDefault();\n    (\n      nextRow.querySelector<HTMLButtonElement>(`button[title=\"Retry\"]`) ??\n      nextRow.querySelector<HTMLButtonElement>(`button[title=\"Remove\"]`)\n    )?.focus();\n  }\n\n  const pasteArmed = hovered || focusWithin;\n  const addRef = useRef(addFiles);\n  useLayoutEffect(() => {\n    addRef.current = addFiles;\n  });\n\n  useEffect(() => {\n    if (!pasteArmed) return;\n    const onPaste = (event: ClipboardEvent) => {\n      const files = event.clipboardData?.files;\n      if (!files?.length) return;\n      const target = event.target as HTMLElement | null;\n      if (\n        target?.closest(\n          \"input:not([type=file]), textarea, [contenteditable='true']\",\n        )\n      )\n        return;\n      event.preventDefault();\n      addRef.current(files);\n    };\n    document.addEventListener(\"paste\", onPaste);\n    return () => document.removeEventListener(\"paste\", onPaste);\n  }, [pasteArmed]);\n\n  const carriesFiles = (event: DragEvent) =>\n    Array.from(event.dataTransfer?.types ?? []).includes(\"Files\");\n  const dropCopy =\n    dropLabel ??\n    (onUpload\n      ? \"Drop to upload\"\n      : multiple\n        ? \"Drop to add files\"\n        : \"Drop to add the file\");\n  const noteCopy =\n    note ??\n    (accept\n      ? `Accepted: ${accept}`\n      : `Up to ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}`);\n\n  const slot = {\n    initial: reduce ? { opacity: 0 } : { height: 0, opacity: 0 },\n    animate: { height: \"auto\", opacity: 1 },\n    exit: reduce\n      ? { opacity: 0, transition: fade }\n      : {\n          height: 0,\n          opacity: 0,\n          transition: { height: collapse, opacity: exitFast },\n        },\n    transition: reduce\n      ? fade\n      : {\n          height: {\n            type: \"spring\" as const,\n            stiffness: 340,\n            damping: 28,\n            mass: 0.8,\n          },\n          opacity: enter,\n        },\n  };\n\n  const rowsInside = inside && items.length > 0;\n\n  const list = (\n    <motion.ul\n      className={cn(\"flex flex-col gap-2.5 w-full\", inside && \"px-3 pb-3\")}\n      aria-label=\"Files\"\n      aria-hidden={items.length ? undefined : true}\n      onKeyDown={moveFocus}\n      initial={false}\n      animate={{\n        paddingTop: items.length && !inside ? 4 : 0,\n      }}\n      transition={\n        reduce\n          ? instant\n          : { type: \"spring\", stiffness: 340, damping: 28, mass: 0.8 }\n      }\n    >\n      <AnimatePresence initial={false}>\n        {items.map((item, index) => (\n          <FileRow\n            key={item.id}\n            item={item}\n            reduce={reduce}\n            delay={\n              reduce\n                ? 0\n                : Math.min(Math.max(0, index - batchStart), 7) *\n                  motionTokens.stagger.item *\n                  1.6\n            }\n            fresh={freshIds.has(item.id)}\n            canRetry={!!onUpload}\n            onRemove={() => removeItem(item)}\n            onRetry={() => retryItem(item)}\n            removeRef={(node) => {\n              if (node) removeRefs.current.set(item.id, node);\n              else removeRefs.current.delete(item.id);\n            }}\n          />\n        ))}\n      </AnimatePresence>\n    </motion.ul>\n  );\n\n  return (\n    <div\n      className={cn(\"w-full flex flex-col gap-3 font-sans\", className)}\n      onPointerEnter={() => setHovered(true)}\n      onPointerLeave={() => setHovered(false)}\n      onFocus={() => setFocusWithin(true)}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n          setFocusWithin(false);\n        }\n      }}\n    >\n      <div\n        ref={zoneRef}\n        className={cn(\n          \"relative w-full rounded-[28px] overflow-hidden select-none transition-colors duration-200 border border-neutral-200 dark:border-white/8 bg-neutral-100/50 dark:bg-[#161618]/60 backdrop-blur-xs\",\n          dragging &&\n            \"border-neutral-400 dark:border-white/25 bg-neutral-200/50 dark:bg-neutral-800/40\",\n        )}\n        onDragEnter={(event) => {\n          if (!carriesFiles(event)) return;\n          event.preventDefault();\n          dragDepth.current += 1;\n          setDragging(true);\n        }}\n        onDragOver={(event) => {\n          if (!carriesFiles(event)) return;\n          event.preventDefault();\n          event.dataTransfer.dropEffect = \"copy\";\n        }}\n        onDragLeave={(event) => {\n          if (!carriesFiles(event)) return;\n          dragDepth.current = Math.max(0, dragDepth.current - 1);\n          if (!dragDepth.current) setDragging(false);\n        }}\n        onDrop={(event) => {\n          event.preventDefault();\n          dragDepth.current = 0;\n          setDragging(false);\n          if (event.dataTransfer.files.length)\n            addFiles(event.dataTransfer.files);\n        }}\n      >\n        <motion.button\n          ref={dropRef}\n          type=\"button\"\n          className=\"relative z-10 w-full flex flex-col items-center justify-center text-center cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-neutral-400/40 rounded-[inherit] px-6\"\n          onClick={() => inputRef.current?.click()}\n          aria-describedby={\n            compact ? descriptionId : `${descriptionId} ${noteId}`\n          }\n          initial={false}\n          animate={{\n            paddingTop: compact ? 16 : 30,\n            paddingBottom: compact\n              ? rowsInside\n                ? 8\n                : 16\n              : rowsInside\n                ? 16\n                : 30,\n          }}\n          transition={\n            reduce\n              ? instant\n              : { type: \"spring\", stiffness: 340, damping: 28, mass: 0.8 }\n          }\n        >\n          <AnimatePresence initial={false}>\n            {(!compact || dragging) && (\n              <motion.span\n                key=\"icon\"\n                className=\"overflow-visible flex flex-col items-center\"\n                {...slot}\n              >\n                <span\n                  className=\"relative flex items-center justify-center w-20 h-16 mb-2.5 overflow-visible\"\n                  aria-hidden=\"true\"\n                >\n                  <motion.span\n                    className=\"absolute w-11 h-14 rounded-2xl bg-neutral-200/90 dark:bg-[#202024] border border-neutral-300 dark:border-white/10 origin-bottom shadow-xs\"\n                    animate={\n                      dragging\n                        ? { rotate: -20, x: -18, y: -1 }\n                        : hovered\n                          ? { rotate: -16, x: -15, y: -1 }\n                          : { rotate: -9, x: -9, y: 0 }\n                    }\n                    transition={{\n                      type: \"spring\",\n                      stiffness: 340,\n                      damping: 24,\n                      mass: 0.8,\n                    }}\n                  />\n                  <motion.span\n                    className=\"absolute w-11 h-14 rounded-2xl bg-neutral-200/90 dark:bg-[#202024] border border-neutral-300 dark:border-white/10 origin-bottom shadow-xs\"\n                    animate={\n                      dragging\n                        ? { rotate: 20, x: 18, y: -1 }\n                        : hovered\n                          ? { rotate: 16, x: 15, y: -1 }\n                          : { rotate: 9, x: 9, y: 0 }\n                    }\n                    transition={{\n                      type: \"spring\",\n                      stiffness: 340,\n                      damping: 24,\n                      mass: 0.8,\n                    }}\n                  />\n                  <motion.span\n                    className=\"relative z-10 w-11 h-14 rounded-2xl bg-white dark:bg-[#26262a] border border-neutral-300 dark:border-white/15 shadow-sm flex flex-col items-center justify-center gap-1.5\"\n                    animate={\n                      dragging\n                        ? { y: -3, scale: 1.03 }\n                        : hovered\n                          ? { y: -2, scale: 1.02 }\n                          : { y: 0, scale: 1 }\n                    }\n                    transition={{\n                      type: \"spring\",\n                      stiffness: 340,\n                      damping: 24,\n                      mass: 0.8,\n                    }}\n                  >\n                    <div className=\"flex flex-col items-center gap-0.5\">\n                      <span className=\"w-5 h-0.5 rounded-full bg-neutral-400 dark:bg-neutral-500\" />\n                      <span className=\"w-3 h-0.5 rounded-full bg-neutral-300 dark:bg-neutral-600\" />\n                    </div>\n                    <ArrowUp\n                      size={14}\n                      strokeWidth={2.25}\n                      className=\"text-neutral-700 dark:text-neutral-200 mt-0.5\"\n                    />\n                  </motion.span>\n                </span>\n              </motion.span>\n            )}\n          </AnimatePresence>\n          <strong className=\"text-base font-semibold text-neutral-900 dark:text-neutral-100 tracking-tight\">\n            <TextSwap\n              text={dragging ? dropCopy : label}\n              className=\"inline-block\"\n              reduce={reduce}\n            />\n          </strong>\n          <span\n            id={descriptionId}\n            className=\"text-sm text-neutral-500 dark:text-neutral-400 mt-1\"\n          >\n            {description}\n          </span>\n          <AnimatePresence initial={false}>\n            {!compact && (\n              <motion.span\n                key=\"note\"\n                className=\"overflow-hidden flex flex-col items-center\"\n                {...slot}\n              >\n                <small\n                  id={noteId}\n                  className=\"text-xs text-neutral-500 dark:text-neutral-500 mt-2 font-normal\"\n                >\n                  {noteCopy}\n                </small>\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </motion.button>\n        {inside && list}\n      </div>\n      <input\n        ref={inputRef}\n        className=\"sr-only\"\n        type=\"file\"\n        accept={accept}\n        multiple={multiple}\n        tabIndex={-1}\n        aria-hidden=\"true\"\n        onChange={(event) => {\n          if (event.target.files) addFiles(event.target.files);\n          event.target.value = \"\";\n        }}\n      />\n      <AnimatePresence initial={false}>\n        {error ? <ErrorRow key=\"error\" text={error} /> : null}\n      </AnimatePresence>\n      {!inside && list}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nexport default FileDropzone;\n",
      "type": "registry:ui",
      "target": "@ui/file-dropzone.tsx"
    },
    {
      "path": "src/lib/motion-tokens.ts",
      "content": "export const motionTokens = {\n  duration: {\n    instant: 0.08,\n    fast: 0.18,\n    standard: 0.35,\n    considered: 0.5,\n  },\n  ease: {\n    enter: [0.16, 1, 0.3, 1] as const,\n    standard: [0.2, 0, 0, 1] as const,\n  },\n  spring: {\n    smooth: { type: \"spring\" as const, bounce: 0, visualDuration: 0.4 },\n    snappy: { type: \"spring\" as const, bounce: 0.15, visualDuration: 0.35 },\n  },\n  blur: {\n    soft: 4,\n    subtle: 2,\n  },\n  stagger: {\n    item: 0.05,\n  },\n};\n",
      "type": "registry:lib"
    }
  ]
}
