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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-drag-drop-files.jsonPrompt
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 counter —
dragenter/dragleavefire 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-leveldrop/dragendlisteners 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
isValidjudges only what it can see and treats everything undecidable as acceptable. It never refuses the drop either:dropEffectstayscopyso 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 drain —
webkitGetAsEntry()andgetAsFile()are valid only while the drop event is being dispatched; oneawaitearlier in the handler and both returnnull. 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 bymaxDepthand amaxEntriesbudget.entry.fullPathbecomesDroppedFile.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: 0is 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()andprocessFiles()bump the same id to cancel a walk already in flight.
useMutationQueue
A durable offline write queue: enqueue with an optimistic id that doubles as an idempotency key, persist it so a reload keeps the work, drain in order when the connection returns, and park a poisoned write instead of letting it block the rest.
useLocale
The reader's resolved locale, time zone, first day of week and numbering system, plus memoised Intl formatters that print the same bytes on the server and after hydration.