Drag-and-drop file hook
A small React hook that turns any element into a file drop zone with an accept filter and a stable dragging flag.
React / Next.jsHooksreactdrag-dropfileshook
Live preview
Code
import { useCallback, useRef, useState, type DragEvent, type ChangeEvent } from 'react';
type Options = { accept?: string[]; multiple?: boolean };
// Matches "image/*", "application/pdf" or ".png" against a file.
function matches(file: File, patterns: string[]): boolean {
if (patterns.length === 0) return true;
return patterns.some((p) => {
if (p.startsWith('.')) return file.name.toLowerCase().endsWith(p.toLowerCase());
if (p.endsWith('/*')) return file.type.startsWith(p.slice(0, -1));
return file.type === p;
});
}
export function useFileDrop(onFiles: (files: File[]) => void, { accept = [], multiple = true }: Options = {}) {
const [dragging, setDragging] = useState(false);
const depth = useRef(0); // enter/leave pairs fire for every child element
const input = useRef<HTMLInputElement | null>(null);
const deliver = useCallback(
(list: FileList | null) => {
const files = Array.from(list ?? []).filter((f) => matches(f, accept));
if (files.length) onFiles(multiple ? files : files.slice(0, 1));
},
[accept, multiple, onFiles],
);
const bind = {
onDragEnter: (e: DragEvent) => {
e.preventDefault();
depth.current += 1;
setDragging(true);
},
onDragOver: (e: DragEvent) => e.preventDefault(), // required to allow a drop
onDragLeave: () => {
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDragging(false);
},
onDrop: (e: DragEvent) => {
e.preventDefault();
depth.current = 0;
setDragging(false);
deliver(e.dataTransfer.files);
},
};
const inputProps = {
ref: input,
type: 'file' as const,
accept: accept.join(','),
multiple,
onChange: (e: ChangeEvent<HTMLInputElement>) => {
deliver(e.target.files);
e.target.value = ''; // allow picking the same file twice
},
};
return { bind, dragging, inputProps, openPicker: () => input.current?.click() };
}
How it works
What it solves
Native drag-and-drop events are noisy. Moving the pointer across child elements fires dragenter and dragleave repeatedly, which makes a naive "is dragging" flag flicker. This hook gives you a reliable flag, an accept filter and a keyboard-friendly click fallback in a few lines.
How it works
The hook keeps a counter in a ref. Each dragenter increments it and each dragleave decrements it, so the dragging state only turns off when the pointer has really left the zone and all of its children. dragover calls preventDefault, which is required for the browser to allow a drop at all. On drop the files are read from dataTransfer, filtered with the accept patterns (supporting image/* wildcards and exact types or extensions) and handed to your callback.
It returns a props object to spread on the zone plus an openPicker function that clicks a hidden input, so the same callback handles both dropped and picked files.
Adapting it
- Pass
multiple: falseto take only the first accepted file. - Add a
maxBytesoption and call anonRejectcallback for oversized files. - Spread
bindonto the document body for a full-page drop target.
Pitfalls
- Without
preventDefaultondragover, the browser opens the dropped file instead of calling you. - File types from
dataTransfercan be empty for unusual extensions; the extension check covers that. - Remember to revoke any object URLs you create from dropped files.
How to use it
- Save as
hooks/useFileDrop.ts. - Call
const { bind, dragging, openPicker, inputProps } = useFileDrop(onFiles, { accept: ['image/*'] }). - Spread
{...bind}on a div and render<input {...inputProps} hidden />once. - Use
draggingto style the highlighted state andopenPicker()from a button.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.