Hooks

useDragDropFiles

File drag and drop as props to spread — a dragenter/dragleave depth counter nested children cannot flicker, an honest drag-time validity preview, dropped folders walked through the FileSystem entry API, and every refusal itemised with a typed reason.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * Why a candidate never reached `files`. **Branch UI copy on `reason`**; `message` is a
 * ready-to-render English fallback, never localised.
 * - `type-not-accepted` — failed the `accept` allow-list.
 * - `too-large` — over `maxSize`.
 * - `too-many-files` — the batch was already full (`maxFiles`, or 1 when `multiple` is false),
 *   or the folder walk hit `maxEntries`.
 * - `directory-skipped` — a folder that was not opened: `recursive` is off, or it sat deeper
 *   than `maxDepth`.
 * - `unreadable` — the browser refused to hand the entry over (moved mid-drag, no permission,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-drag-drop-files.json

Prompt

Build a React + TypeScript "useDragDropFiles" hook (React only — no npm
dependencies; native HTML5 drag & drop plus the FileSystem entry API reached
through DataTransferItem.webkitGetAsEntry()). It renders nothing: it returns
props to spread on whatever element the consumer already has.

Contract
- `useDragDropFiles({ onFiles, accept, maxSize, maxFiles, multiple = true,
  recursive = true, maxDepth = 8, maxEntries = 1000, disabled = false } = {})`.
- Returns `{ dropProps, isOver, isValid, isPending, files, rejections, clear,
  processFiles }`.
- `dropProps` is `{ onDragEnter, onDragOver, onDragLeave, onDrop,
  "data-drag-state", "aria-disabled" }` and nothing else — no className, no
  ref, no tabIndex — so it can be spread onto any host element without
  fighting the consumer's own props. `data-drag-state` is
  `"idle" | "valid" | "invalid"`, the (isOver, isValid) pair collapsed into
  one attribute so styling is pure CSS:
  `data-[drag-state=valid]:border-primary`.
- `files: DroppedFile[]` where `DroppedFile` is `{ file: File, path: string }`.
  `path` is relative to what was dropped — "invoices/2026/q1.pdf" for a file
  found inside a dropped folder, the bare name for a loose file. It is the
  only place the folder structure survives: a `File` carries no path, and
  `webkitRelativePath` is empty for files produced by a drag.
- `rejections: DragDropRejection[]` where `DragDropRejection` is
  `{ file: File | null, path: string, reason, message }` and `reason` is
  `"type-not-accepted" | "too-large" | "too-many-files" |
  "directory-skipped" | "unreadable"`. `file` is null when there is nothing
  to hand back (a folder that was never opened). Branch UI copy on `reason`;
  `message` is a displayable English fallback, never localised.
- `onFiles(result)` with `result` = `{ files, rejections, source }` and
  `source` in `"drop" | "picker"`.
- `processFiles(incoming: ArrayLike<File> | null | undefined)` runs the same
  gates over files that never came from a drag, returns the result AND
  publishes it. `clear()` empties both lists. Both keep one identity for the
  component's life, so they are safe in dependency arrays.
- `files` and `rejections` report the LATEST batch; the hook never
  accumulates. A running queue is the consumer's state, and
  `maxFiles={capacity - used}` turns "the queue is full" into an ordinary
  rejection.

Behavior
- isOver comes from a DEPTH COUNTER in a ref, not a boolean. dragenter and
  dragleave fire once for every node the pointer crosses, so a zone with an
  icon and two lines of text flips a naive boolean several times per second
  while the pointer is still comfortably inside. Increment on enter,
  decrement on leave (clamped at 0), read and write the ref in the same
  synchronous pass, and only touch state when the count crosses 0 → 1 or
  1 → 0: two state changes per drag, whatever the markup depth.
- Only drags whose `dataTransfer.types` contains "Files" count at all. A text
  or link drag never highlights the zone and is never accepted.
- isValid is a PREVIEW and must say so. Mid-drag the browser exposes item
  count and MIME type and nothing else — no names, no sizes. So: count is
  checkable; MIME is checkable only for items that report one (folders and
  unknown extensions report ""); and an `accept` list containing any
  extension rule is not checkable at all, because a file failing every MIME
  rule may still pass on its name. Everything undecidable counts as valid.
  isValid is meaningless while isOver is false (it reads true).
- Keep `dropEffect: "copy"` even while the preview says invalid, and let the
  drop land: an itemised list of what was wrong beats a mute no-drop cursor.
  Only `disabled` sets `dropEffect: "none"`. Re-set dropEffect on every
  dragover — the browser resets it before each one.
- dragover always calls preventDefault() for file drags, including while
  disabled. This is not a formality: preventing the default is what makes the
  element a drop target, and an element that is not a drop target hands the
  file to the browser, which navigates away to open it and takes whatever the
  user had typed with it.
- The drop handler drains dataTransfer SYNCHRONOUSLY. `webkitGetAsEntry()`
  and `getAsFile()` are only valid while the event is being dispatched; after
  any await the item list is empty and both return null — the number one
  reason a drop handler "gets no files". Collect every entry and file in one
  straight-line pass, then filter, then walk.
- Folders: for each item prefer `webkitGetAsEntry()`; fall back to the flat
  `dataTransfer.files` when no item yields an entry. Walk depth-first, and
  loop `readEntries()` until it returns an EMPTY batch — Chromium hands back
  at most 100 entries per call, so the single-call version silently truncates
  a folder of 250 photos to 100 with no error anywhere. Cap the loop
  (1000 batches) so a misbehaving implementation cannot spin forever.
  `entry.file()` and `readEntries()` are callback APIs: wrap each in a
  promise, and turn a failure into an `"unreadable"` rejection rather than
  losing the rest of the tree — half a folder is still a result.
- Budgets: folders deeper than `maxDepth` come back as `"directory-skipped"`;
  the walk stops after visiting `maxEntries` entries and reports a single
  `"too-many-files"` row naming the folder it stopped in, so dropping a home
  directory degrades instead of hanging. With `recursive: false` a folder is
  reported as one `"directory-skipped"` row instead of being opened.
- Gates run per file in this order: accept → maxSize → remaining capacity. The
  order is load-bearing: a file refused on type or size must not consume a
  capacity slot, otherwise one stray PDF pushes out an image that would have
  fitted. `accept` matches three shapes — wildcard "image/*", exact
  "application/pdf", extension ".csv" — and the extension form earns its keep
  because files walked out of a folder often arrive with an empty file.type.
  `multiple: false` pins the limit to 1 whatever maxFiles says. maxFiles: 0 is
  legal and means "no capacity left".
- Nothing disappears quietly. Every candidate that fails a gate appears in
  `rejections` with its reason, its path and a message.
- An intake that produced neither a file nor a rejection is not an event: do
  not call onFiles and do not touch the published state. Dragging a text
  selection over the zone, or dropping an empty folder, leaves the previous
  batch exactly where it was.
- Latest intake wins. Take a monotonic run id in a ref — read and written
  synchronously inside the drop handler — and have the async walk compare it
  before publishing. A slow folder started first can then never overwrite a
  fast drop that came later, and `clear()` / `processFiles()` bump the same id
  to cancel a walk in flight.
- Cleanup: window-level `drop` and `dragend` listeners (capture phase, so a
  stopPropagation() elsewhere on the page cannot hide them) force the counter
  back to 0 for drags that end somewhere else entirely — otherwise the zone
  stays lit forever. Both are removed on unmount, alongside a mountedRef that
  is set inside the mount effect (not only cleared in cleanup, which would
  mark a live instance dead under StrictMode's mount → cleanup → mount) so a
  walk that resolves after unmount never calls setState.
- Options and onFiles live in latest-refs and are read at event time, so an
  inline options literal plus an inline arrow — what every consumer writes —
  never re-create the handlers or re-arm the window listeners.

Rendering & styling
- The hook owns no DOM. The zone, its copy, its icon and its layout stay with
  the consumer; only `data-drag-state` and `aria-disabled` are contributed.
- Suggested zone styling, semantic tokens only: idle
  `border border-dashed bg-muted/30`; `data-[drag-state=valid]:border-primary
  data-[drag-state=valid]:bg-primary/5`;
  `data-[drag-state=invalid]:border-destructive
  data-[drag-state=invalid]:bg-destructive/10`; `aria-disabled:opacity-60`;
  rejection rows `border-destructive/40 bg-destructive/10` with body copy in
  `text-foreground` (muted copy over that tint measures below AA) and the
  reason as a solid-fill badge. Merge className with cn(). Wrap the colour
  change in `transition-colors motion-reduce:transition-none` and give any
  pending spinner `motion-reduce:animate-none` — the state is carried by text
  and colour, so nothing breaks with motion off.
- KEYBOARD: drag and drop has no keyboard equivalent, in any browser. There
  is no key map to add and no ARIA role that makes a drop zone operable, so a
  zone on its own is unreachable for keyboard and screen-reader users. Put a
  real `<button type="button">` next to it that clicks a hidden
  `<input type="file">`, and feed `input.files` to `processFiles()` — the same
  gates, the same result. Reset `input.value` afterwards so picking the same
  file twice still fires change.
- ARIA: mark a switched-off zone with `aria-disabled` (never the native
  `disabled` attribute on the buttons around it — a control the user may be
  focused on must keep its place in the tab order, with a guard in the
  handler doing the actual refusing). Announce the outcome, not the drag:
  render `files.length` / `rejections.length` inside an always-mounted
  `aria-live="polite"` region, since the drag itself is invisible to a screen
  reader. Give decorative icons `aria-hidden`.

Customization levers
- `accept` / `maxSize` / `maxFiles` / `multiple` — the intake policy. Wire
  maxFiles to remaining queue capacity to get "queue full" refusals for free.
- `recursive` / `maxDepth` / `maxEntries` — how much of a dropped folder you
  are willing to read. Turn recursive off for an avatar picker; raise
  maxEntries for a media ingest tool that expects thousands of files.
- `data-drag-state` — the whole visual language. Style it as a dashed outline,
  a tinted overlay, a scale nudge, or nothing at all; the hook has no opinion.
- `onFiles` — where the upload, the optimistic queue row or the parse hangs
  off. The hook selects and validates; it never transfers anything.
- `processFiles` — the second entrance. Point a file input, a paste handler or
  a test at it and everything downstream stays identical.
- Want dedupe, image dimension limits or a total-size budget? Do it inside
  onFiles on `file.size` / `name + size + lastModified`; the hook ships only
  the gates that need drag knowledge and leaves policy to you.

Concepts

  • Drag-depth counterdragenter / dragleave fire once per node crossed, so a boolean strobes while the pointer wanders over a zone's icon and captions. A counter in a ref, incremented and decremented in the same synchronous pass and clamped at zero, only crosses 0 → 1 and 1 → 0 at the real boundary: exactly two state changes per drag, however deep the markup. Window-level drop / dragend listeners force it back to zero for drags that end elsewhere, so the highlight cannot stick.
  • Optimistic drag preview — mid-drag a browser exposes item count and MIME type and withholds names and sizes, so isValid judges only what it can see and treats everything undecidable as acceptable. It never refuses the drop either: dropEffect stays copy so the real gate can run and explain itself, because an itemised refusal is more useful than a no-drop cursor that says nothing.
  • Synchronous dataTransfer drainwebkitGetAsEntry() and getAsFile() are valid only while the drop event is being dispatched; one await earlier in the handler and both return null. Every entry and file is therefore collected in one straight-line pass before any filtering, state update or folder walk begins.
  • Directory entry walk — a dropped folder is opened through the FileSystem entry API, depth-first, with readEntries() looped until it returns an empty batch (Chromium caps each call at 100 entries and reports no error when it truncates), bounded by maxDepth and a maxEntries budget. entry.fullPath becomes DroppedFile.path, the only trace of the folder structure that survives the trip.
  • Itemised rejection — gates run accept → maxSize → capacity so a file refused on type or size never eats a capacity slot, and every refusal comes back with a typed reason, its path and a message. maxFiles: 0 is a first-class value meaning the queue is full, and a batch that yields neither files nor rejections is treated as a non-event rather than clearing what was there.
  • Latest intake wins — a monotonic run id, read and written synchronously in the drop handler, is compared again before the asynchronous walk publishes anything, so a large folder dropped first can never overwrite a small drop that came second; clear() and processFiles() bump the same id to cancel a walk already in flight.

On This Page