{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-dropzone",
  "title": "File Dropzone",
  "description": "A drag-and-drop file upload area that is also a real file picker: drop files onto it, or click it — or focus it and press Enter or Space — to open the native chooser. Reach for it at the point a product takes a file in: an avatar or profile photo upload, a logo in brand settings, an image or gallery upload, a CSV/TSV/XLSX import step, a document, PDF, contract or resume upload, receipts and invoices, ID and KYC documents, attachments on a ticket, issue, message or email composer, a bulk media or photo drop, a dataset or training-corpus upload for an AI app, a .zip or backup restore, and the file step of an onboarding or import wizard. Common asks it answers: \"file dropzone\", \"drag and drop file upload\", \"drop zone react\", \"drag drop upload area\", \"upload box\", \"click to browse files\", \"file picker component\", \"shadcn file input\", \"shadcn file upload\", \"shadcn dropzone\", \"react-dropzone alternative\", \"filepond alternative\", \"uppy alternative\", \"csv upload component\", \"image upload dropzone\", \"multiple file upload react\", \"accept only images\", \"limit file size on upload\", \"restrict file types react\". The gap in official shadcn/ui is the taking-in half, and it is worth being precise about it now that the catalogue has grown: input covers text-like types and never file, and attachment — its newer component in this area — renders a file that has already been attached, with media, title, description and actions, but contains no `<input type=\"file\">`, no drop target, and no accept or size filtering. Nothing there receives a file. What is hand-rolled every time and easy to get wrong: a drop target only works if `dragover` is cancelled, and a version that skips it drops the file straight into the browser, which navigates away from the app and takes any unsaved form with it — so `dragover` is cancelled here. The `accept` attribute is also filtering theatre on a drop: the browser applies it to the chooser dialog only, and anything dragged in arrives unfiltered, so the same rules are applied a second time in JavaScript. Dropped and picked files therefore go through one path and one filter — `accept` (a mime type, a wildcard like image/*, or a .ext), `maxSize` in bytes, and `maxFiles` — and everything skipped comes back through `onReject` tagged with the reason it was skipped (\"type\", \"size\" or \"too-many\"), so the UI can say why instead of swallowing the file and looking broken. It wraps a hidden real `<input type=\"file\">` rather than simulating one, so the control still submits with a form, still takes `name` and `required`, and still forwards a ref for a caller who wants to open the chooser from a button elsewhere. Works controlled or uncontrolled over a `File[]`, single or multiple, with a highlighted drag-over state and a disabled state that also leaves the tab order. Accessible without a mouse or a screen: the region is a `role=\"button\"` with a real tab stop, activated by Enter and Space, drawn with a focus-visible ring, marked `aria-disabled` when disabled, and every add and every rejection is announced through a polite live region — the part hand-rolled dropzones almost always omit, which leaves a screen-reader user with no confirmation that a dropped file landed or any idea why it did not. It hands back a `File[]` and deliberately stops there — no endpoint, no auth, no progress source it would have to guess. Pair it with upload-list for the rows that show what happened to those files. Styled with shadcn tokens so it follows light and dark mode; the only dependency is lucide-react, for the upload icon.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/file-dropzone.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Upload } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** Why a candidate file was skipped, so you can surface it to the user. */\nexport interface FileRejection {\n  file: File\n  reason: \"type\" | \"size\" | \"too-many\"\n}\n\ninterface FileDropzoneProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"onChange\" | \"onDrop\" | \"defaultValue\"\n  > {\n  /** Controlled list of accepted files. Pair with `onChange` to own the state. */\n  value?: File[]\n  /** Initial files when uncontrolled. */\n  defaultValue?: File[]\n  /** Called with the next list whenever files are added or one is cleared. */\n  onChange?: (files: File[]) => void\n  /** Called with the files that were skipped and why (bad type, too big, over the cap). */\n  onReject?: (rejections: FileRejection[]) => void\n  /** Same syntax as an <input> accept attr, e.g. \"image/*,.pdf\". Also filters drops. */\n  accept?: string\n  /** Allow selecting more than one file (default false). */\n  multiple?: boolean\n  /** Reject files larger than this many bytes. */\n  maxSize?: number\n  /** Cap the total number of files kept. */\n  maxFiles?: number\n  disabled?: boolean\n}\n\nfunction extOf(name: string) {\n  const dot = name.lastIndexOf(\".\")\n  return dot === -1 ? \"\" : name.slice(dot).toLowerCase()\n}\n\n/** Match a file against an `accept` string (mime, mime wildcard, or .ext). */\nfunction matchesAccept(file: File, accept?: string) {\n  if (!accept) return true\n  const type = file.type.toLowerCase()\n  const ext = extOf(file.name)\n  return accept\n    .split(\",\")\n    .map((token) => token.trim().toLowerCase())\n    .filter(Boolean)\n    .some((token) => {\n      if (token.startsWith(\".\")) return ext === token\n      if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1))\n      return type === token\n    })\n}\n\nexport const FileDropzone = React.forwardRef<HTMLInputElement, FileDropzoneProps>(\n  function FileDropzone(\n    {\n      className,\n      children,\n      value,\n      defaultValue,\n      onChange,\n      onReject,\n      accept,\n      multiple = false,\n      maxSize,\n      maxFiles,\n      disabled,\n      ...props\n    },\n    forwardedRef\n  ) {\n    const inputRef = React.useRef<HTMLInputElement>(null)\n    React.useImperativeHandle(\n      forwardedRef,\n      () => inputRef.current as HTMLInputElement\n    )\n\n    const isControlled = value !== undefined\n    const [internal, setInternal] = React.useState<File[]>(\n      () => (isControlled ? value : defaultValue) ?? []\n    )\n    const files = isControlled ? (value as File[]) : internal\n\n    const [dragging, setDragging] = React.useState(false)\n    // Visually-hidden message so screen readers hear each add/reject.\n    const [announce, setAnnounce] = React.useState(\"\")\n    // A drop can fire nested dragleave/dragenter; count depth to avoid flicker.\n    const dragDepth = React.useRef(0)\n\n    function commit(next: File[], message: string) {\n      if (!isControlled) setInternal(next)\n      setAnnounce(message)\n      onChange?.(next)\n    }\n\n    function ingest(incoming: FileList | File[]) {\n      if (disabled) return\n      const candidates = Array.from(incoming)\n      const passed: File[] = []\n      const rejected: FileRejection[] = []\n\n      for (const file of candidates) {\n        if (!matchesAccept(file, accept)) {\n          rejected.push({ file, reason: \"type\" })\n        } else if (maxSize !== undefined && file.size > maxSize) {\n          rejected.push({ file, reason: \"size\" })\n        } else {\n          passed.push(file)\n        }\n      }\n\n      let next: File[]\n      if (multiple) {\n        next = [...files, ...passed]\n        if (maxFiles !== undefined && next.length > maxFiles) {\n          for (const file of next.slice(maxFiles)) {\n            rejected.push({ file, reason: \"too-many\" })\n          }\n          next = next.slice(0, maxFiles)\n        }\n      } else {\n        // A single-file dropzone keeps only the last valid file; the earlier ones\n        // are over the cap, so report them through onReject instead of dropping\n        // them silently.\n        next = passed.slice(-1)\n        for (const file of passed.slice(0, -1)) {\n          rejected.push({ file, reason: \"too-many\" })\n        }\n      }\n\n      if (rejected.length) onReject?.(rejected)\n      if (passed.length || !rejected.length) {\n        const added = next.length - files.length\n        commit(\n          next,\n          rejected.length\n            ? `Added ${Math.max(added, 0)} file${added === 1 ? \"\" : \"s\"}, ${rejected.length} skipped`\n            : `Added ${passed.length} file${passed.length === 1 ? \"\" : \"s\"}`\n        )\n      } else {\n        setAnnounce(`${rejected.length} file${rejected.length === 1 ? \"\" : \"s\"} skipped`)\n      }\n    }\n\n    function open() {\n      if (!disabled) inputRef.current?.click()\n    }\n\n    function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {\n      if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault()\n        open()\n      }\n    }\n\n    function handleDragEnter(e: React.DragEvent<HTMLDivElement>) {\n      e.preventDefault()\n      if (disabled) return\n      dragDepth.current += 1\n      setDragging(true)\n    }\n\n    function handleDragLeave(e: React.DragEvent<HTMLDivElement>) {\n      e.preventDefault()\n      if (disabled) return\n      dragDepth.current -= 1\n      if (dragDepth.current <= 0) {\n        dragDepth.current = 0\n        setDragging(false)\n      }\n    }\n\n    function handleDrop(e: React.DragEvent<HTMLDivElement>) {\n      e.preventDefault()\n      dragDepth.current = 0\n      setDragging(false)\n      if (disabled) return\n      if (e.dataTransfer?.files?.length) ingest(e.dataTransfer.files)\n    }\n\n    return (\n      <div\n        role=\"button\"\n        tabIndex={disabled ? -1 : 0}\n        aria-disabled={disabled || undefined}\n        data-dragging={dragging || undefined}\n        onClick={open}\n        onKeyDown={handleKeyDown}\n        onDragEnter={handleDragEnter}\n        onDragOver={(e) => e.preventDefault()}\n        onDragLeave={handleDragLeave}\n        onDrop={handleDrop}\n        className={cn(\n          \"flex min-h-32 w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-input bg-transparent p-6 text-center text-sm text-muted-foreground transition-colors hover:border-ring/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring data-[dragging]:border-ring data-[dragging]:bg-accent/50\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          className\n        )}\n        {...props}\n      >\n        <input\n          ref={inputRef}\n          type=\"file\"\n          accept={accept}\n          multiple={multiple}\n          disabled={disabled}\n          className=\"sr-only\"\n          onChange={(e) => {\n            if (e.target.files?.length) ingest(e.target.files)\n            // Reset so selecting the same file again re-fires onChange.\n            e.target.value = \"\"\n          }}\n          tabIndex={-1}\n        />\n        {children ?? (\n          <>\n            <Upload className=\"h-6 w-6\" aria-hidden=\"true\" />\n            <div>\n              <span className=\"font-medium text-foreground\">\n                Click to upload\n              </span>{\" \"}\n              or drag and drop\n            </div>\n            {accept ? <div className=\"text-xs\">{accept}</div> : null}\n          </>\n        )}\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {announce}\n        </span>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}