ESC

Type to search the knowledge base.

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.

intermediate8 min read
  • 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

  • dragover must preventDefault or drop never fires. drop should preventDefault too, or the browser may navigate to the file.
  • Hidden <input type="file"> is the keyboard and click path. The drop target activates it.
  • accept is a hint. Recheck MIME and size in JavaScript and reject before onFiles.
  • Show accepted files with a Remove button per row. Revoke object URLs you create.
  • No fake POST /upload. Hand the File list to the parent.

dragover preventDefault, input fallback, and the type/size reject

The drag-and-drop events that matter:

  1. dragenter / dragover: you are a drop target if you cancel the default on dragover.
  2. drop: read e.dataTransfer.files.
  3. 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

  • dragover preventDefault, working drop
  • 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 accepted Files only

Should have

  • Visible “over” state
  • Error text for rejects (role="alert")
  • multiple
  • accept passed 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)

  1. Drop: dragover + drop preventDefault, log files
  2. Input fallback wired to the same ingest
  3. Gate type and size, split ok / rejected
  4. List + remove
  5. 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

  1. No preventDefault on dragover, drop never fires
  2. No preventDefault on drop, the tab navigates to the file
  3. Trusting accept
  4. Mouse-only zone, input missing or disabled
  5. dragleave flicker from children
  6. Object URLs never revoked
  7. 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.

Further reading