File Dropzone Upload
dragover must preventDefault or the drop never fires. Keep a hidden file input as the keyboard path, and reject by type and size before upload.
- machine-coding
- interview
- files
- a11y
If drop does nothing, you forgot preventDefault on dragover. The HTML drag-and-drop model will not fire drop unless that target has already opted in. A file dropzone is that listener, a hidden input type="file" so keyboard and click still work, and a type/size gate that runs before any upload function.
Do not invent a server. The component accepts files, rejects some, lists the rest, and calls onFiles. Bytes on the wire are out of scope unless they add it after you are done.
Key takeaways
dragovermustpreventDefaultordropnever fires.dropshouldpreventDefaulttoo, or the browser may navigate to the file.- Hidden
<input type="file">is the keyboard and click path. The drop target activates it.acceptis a hint. Recheck MIME and size in JavaScript and reject beforeonFiles.- Show accepted files with a Remove button per row. Revoke object URLs you create.
- No fake
POST /upload. Hand theFilelist to the parent.
dragover preventDefault, input fallback, and the type/size reject
The drag-and-drop events that matter:
dragenter/dragover: you are a drop target if you cancel the default ondragover.drop: reade.dataTransfer.files.dragleave: clear the “over” style without fighting child leaves.
function onDragOver(e: React.DragEvent) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
function onDrop(e: React.DragEvent) {
e.preventDefault();
const list = e.dataTransfer.files;
ingest(Array.from(list));
}
That is the whole trick. preventDefault on dragover is in the HTML spec’s drag data store flow: without it the drop is cancelled. People bind only onDrop and debug for half the round. Capture, bubble, once, passive is relevant if a parent passive: true listener sits on touchmove; for dragover, your React handler is not passive by default.
dragleave fires when the pointer crosses a child. A highlight that flickers is a counter:
const depth = useRef(0);
function onDragEnter() {
depth.current += 1;
setOver(true);
}
function onDragLeave() {
depth.current -= 1;
if (depth.current <= 0) {
depth.current = 0;
setOver(false);
}
}
function onDrop(e: React.DragEvent) {
e.preventDefault();
depth.current = 0;
setOver(false);
ingest(Array.from(e.dataTransfer.files));
}
Keyboard path: do not make the dropzone mouse-only. A visually hidden file input, multiple, labelled by the zone text. Clicking the zone calls input.click(). Enter/Space on a <button> that wraps the same path also works. A <label htmlFor={inputId}> is the least JavaScript. Forms, labels, and inputs.
<label htmlFor={inputId} className={over ? "zone over" : "zone"}>
<input
id={inputId}
type="file"
multiple
accept={accept}
className="sr-only"
onChange={(e) => {
ingest(Array.from(e.target.files ?? []));
e.target.value = "";
}}
/>
Drop files here or press Enter to browse
</label>
Resetting e.target.value to "" lets the user pick the same file twice. Without that, onChange does not fire.
Reject before upload:
type Reject = { file: File; reason: string };
function matchAccept(file: File, accept: string) {
if (!accept.trim()) return true;
const rules = accept.split(",").map((s) => s.trim().toLowerCase());
const name = file.name.toLowerCase();
const type = file.type.toLowerCase();
return rules.some((rule) => {
if (rule.startsWith(".")) return name.endsWith(rule);
if (rule.endsWith("/*")) return type.startsWith(rule.slice(0, -1));
return type === rule;
});
}
function gate(
files: File[],
accept: string,
maxBytes: number
): { ok: File[]; rejected: Reject[] } {
const ok: File[] = [];
const rejected: Reject[] = [];
for (const file of files) {
if (!matchAccept(file, accept)) {
rejected.push({ file, reason: "type not allowed" });
continue;
}
if (file.size > maxBytes) {
rejected.push({ file, reason: `larger than ${maxBytes} bytes` });
continue;
}
ok.push(file);
}
return { ok, rejected };
}
accept="image/png,.jpg" is not enforcement. OS file pickers still return a .pdf. file.type can be empty; the extension branch covers that. Empty files (size === 0) are a product choice. Mention them, do not silently upload.
onFiles(ok) is the parent contract. Do not fetch('https://example.com/upload') with a made-up token. If they want progress bars after, queueable progress bars is a separate widget. Previews use object URLs; revoke on remove and unmount. Blob, File, and object URLs.
Problem statement
Build a file dropzone. Users can drop files or open a picker with the keyboard. Reject disallowed types and oversize files before any upload callback. List accepted files with remove. Do not implement a real server.
Requirements
Must have
dragoverpreventDefault, workingdrop- Hidden or visually hidden
input type="file" - Click / keyboard opens the picker
- Reject by type and size in JS
- List + remove
onFiles(or equivalent) with acceptedFiles only
Should have
- Visible “over” state
- Error text for rejects (
role="alert") multipleacceptpassed to the input and rechecked
Nice to have
- Image thumbnails via
URL.createObjectURL - Paste from clipboard (
Ctrl+V+clipboardData.files) - Per-file progress after they define an upload function
Planning (5 minutes out loud)
- Drop:
dragover+droppreventDefault, logfiles - Input fallback wired to the same
ingest - Gate type and size, split ok / rejected
- List + remove
- Say out loud you are not posting to a server
Architecture
FileDropzone
├── Label / zone
│ └── input[type=file].sr-only
├── Errors (alert)
└── List
└── Row (name, size, Remove)
API
type FileDropzoneProps = {
accept?: string;
maxBytes?: number;
onFiles?: (files: File[]) => void;
};
Implementation sketch
function FileDropzone({
accept = "image/png,image/jpeg,.png,.jpg,.jpeg",
maxBytes = 2 * 1024 * 1024,
onFiles,
}: FileDropzoneProps) {
const [items, setItems] = useState<File[]>([]);
const [rejected, setRejected] = useState<Reject[]>([]);
const [over, setOver] = useState(false);
const depth = useRef(0);
const inputId = useId();
function ingest(incoming: File[]) {
const { ok, rejected: bad } = gate(incoming, accept, maxBytes);
setRejected(bad);
if (ok.length === 0) return;
const next = [...items, ...ok];
setItems(next);
onFiles?.(next);
}
function remove(name: string, lastModified: number) {
const next = items.filter(
(f) => !(f.name === name && f.lastModified === lastModified)
);
setItems(next);
onFiles?.(next);
}
function onDragOver(e: React.DragEvent) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}
function onDragEnter(e: React.DragEvent) {
e.preventDefault();
depth.current += 1;
setOver(true);
}
function onDragLeave() {
depth.current -= 1;
if (depth.current <= 0) {
depth.current = 0;
setOver(false);
}
}
function onDrop(e: React.DragEvent) {
e.preventDefault();
depth.current = 0;
setOver(false);
ingest(Array.from(e.dataTransfer.files));
}
return (
<div>
<label
htmlFor={inputId}
className={over ? "zone over" : "zone"}
onDragOver={onDragOver}
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
<input
id={inputId}
type="file"
multiple
accept={accept}
className="sr-only"
onChange={(e) => {
ingest(Array.from(e.target.files ?? []));
e.target.value = "";
}}
/>
Drop images here, or browse
</label>
{rejected.length > 0 ? (
<ul role="alert">
{rejected.map((r) => (
<li key={`${r.file.name}-${r.file.lastModified}`}>
{r.file.name}: {r.reason}
</li>
))}
</ul>
) : null}
<ul>
{items.map((f) => (
<li key={`${f.name}-${f.lastModified}`}>
{f.name} ({f.size} bytes)
<button type="button" onClick={() => remove(f.name, f.lastModified)}>
Remove {f.name}
</button>
</li>
))}
</ul>
</div>
);
}
File has no stable id. name + lastModified + size is an interview-grade key. Two identical copies dropped together can collide; if they press that, tag with an incrementing clientId at ingest.
Object URL preview:
useEffect(() => {
const urls = items
.filter((f) => f.type.startsWith("image/"))
.map((f) => URL.createObjectURL(f));
return () => {
for (const u of urls) URL.revokeObjectURL(u);
};
}, [items]);
Create URLs when you add, revoke the ones you remove. A leak of object URLs is a real memory bug, not a footnote.
This is not the same problem as drag and drop list reorder. That one reorders your own items. This one receives File objects from the OS.
Accessibility essentials
| Piece | Approach |
|---|---|
| Zone | <label> for the file input, or a button that calls input.click() |
| Input | Not display: none if that breaks some AT; visually hidden still in the tree |
| Keyboard | Tab to the control, Enter/Space opens the picker |
| Errors | role="alert" with filename and reason, not color alone |
| Remove | Button named “Remove” plus the file name |
| Status | Optional polite live “3 files added” |
display: none on the file input is a known AT lottery. sr-only (clip / visually hidden) keeps it focusable. If both the label and the input are tab stops, hide the input from the tab order with tabIndex={-1} and let the label receive the click. Pick one tab stop.
Drag-and-drop is enhancement. WCAG does not let you ship drop-only.
Interview scoring
| Signal | What they want |
|---|---|
dragover |
preventDefault, they will ask why |
| Input | Keyboard path exists |
| Gate | Type and size before onFiles |
accept |
Hint plus JS, not trust |
| List | Remove works |
| Scope | No invented upload API |
They may drop a 10 MB .exe on an image-only zone. If it lands in items, you failed the gate. If drop does nothing until they click, you failed dragover.
Footguns
- No
preventDefaultondragover, drop never fires - No
preventDefaultondrop, the tab navigates to the file - Trusting
accept - Mouse-only zone, input missing or
disabled dragleaveflicker from children- Object URLs never revoked
- Posting to a fictional endpoint instead of
onFiles
dataTransfer.items vs files: for a file drop from the desktop, files is enough. items matters for directory entries, which you should decline unless they ask.
Interview out-loud answer
I would cancel the default on dragover so the element is a drop target, and cancel drop so the browser does not navigate. Drop and the hidden file input both go through one ingest function. That function rejects by MIME or extension and by max byte size before it calls onFiles. accept on the input is only a hint. Accepted files render in a list with a named Remove button. Keyboard users tab to the labelled control and open the picker. I would not implement a server in this round.
Related on this site
- Blob, File, and object URLs
- Drag and Drop List Reorder
- Capture, bubble, once, passive
- Forms, Labels, and Inputs
- Queueable Progress Bars
- Keyboard Accessibility Checklist