Drag-and-Drop File Uploads

A drop zone feels effortless to the user and hides a sharp edge for the engineer: the browser’s default behaviour is to navigate away and open the dropped file, so a single missing preventDefault() silently destroys the feature and replaces your application with a JPEG. The second edge is that what arrives in the drop event is frequently not a file at all — it is a directory, a URL string from another tab, or a zero-byte phantom that only reveals itself three layers into your upload queue.

This guide builds a production drop zone in TypeScript that survives all of that: the full event sequence with the two mandatory cancellations, the drag data store and why it is deliberately blind during the drag, a synchronous item-routing pass that separates real files from folders and URLs, batching so a 400-file drop does not freeze the tab, and a keyboard-accessible fallback that works without a mouse.

Drag-and-drop sits inside the upload fundamentals and browser APIs layer, and its entire job is acquisition: turning a gesture into a clean File[]. Once you hold those objects you read them with the File API and Blob objects and transmit them with the modern Fetch API for uploads. Nothing below concerns the network.

Prerequisites

  • [ ] Node 20+ and a bundler that transpiles TypeScript (Vite 5, esbuild 0.20+, or tsc directly)
  • [ ] A browser target of Chromium 90+, Firefox 90+, or Safari 15.4+
  • [ ] lib: ["DOM", "DOM.Iterable", "ES2022"] in tsconfig.json — without DOM.Iterable, for (const item of dataTransfer.items) will not typecheck
  • [ ] Familiarity with the File and Blob interfaces
  • [ ] A destination for the accepted files: a fetch handler, or the S3 presigned URL workflows you already issue from your API
  • [ ] A CSS baseline where the drop zone has a non-zero height even when empty; a collapsed zone cannot receive dragenter

How drag-and-drop works under the hood

The HTML Drag and Drop API is a state machine driven by a loop inside the browser’s event loop, not a stream of ordinary pointer events. When a drag begins — from the operating system’s file manager, from another browser tab, or from an element with draggable="true" — the browser creates a drag data store and starts iterating. On each tick it computes the element under the pointer (the “immediate user selection”), promotes it or one of its ancestors to the “current target element”, and dispatches events accordingly.

Four of those events land on your drop target. dragenter fires once when the dragged item first crosses the element’s boundary. dragover then repeats for as long as the pointer stays inside — on pointer movement, and additionally on a heartbeat the specification pegs at roughly every 350 ms even when the mouse is completely still. dragleave fires when the pointer exits, and drop fires once when the user releases the button over a target that has accepted the drag.

The counter-intuitive part is that the browser defaults to refusing the drop. On every tick the drag loop resets the current drag operation to none and only keeps it if your dragover handler cancels the event. If the final tick before release left the operation at none, the browser dispatches dragleave instead of drop and performs its own default action: navigating the top-level document to the dropped file’s URL. That is why a page with a drop handler and no dragover handler appears to “do nothing” — the handler is not broken, it was never invited to run.

dragenter matters for a subtler reason. Cancelling dragenter is how you claim an element as a drop target in the first place on some engines; cancelling dragover is how you keep the claim alive tick after tick. Cancel both and every engine behaves the same way.

Drop target event lifecycle and the two mandatory preventDefault calls A left-to-right timeline of dragenter, dragover, dragleave and drop, showing which events must be cancelled and when file bytes become reachable. Drop target event lifecycle dragenter once, on entry dragover repeats every 350 ms dragleave also on children drop once, on release cancel: advised cancel: REQUIRED cancel: optional cancel: REQUIRED While dragging: protected mode files is empty and getAsFile() returns null; only types, items.length, kind and type are exposed On drop: read-only mode files is populated; getAsFile() and webkitGetAsEntry() both return real objects Skip preventDefault on dragover and drop never fires — the browser opens the file in the tab instead.
Two of the four drop-target events must be cancelled, and file bytes only become reachable in the last one.

The drag data store and its three modes

Every drag owns exactly one drag data store, and the DataTransfer object you receive is a thin, mode-gated view onto it. The specification defines three modes, and which one is active depends purely on which event is being dispatched:

Read/write applies during dragstart only. This is the one event where you may call setData(), clearData(), items.add() and setDragImage(). It exists for in-page drag sources — reordering a queue, dragging a thumbnail out of a gallery — and never applies to a drag that originated in the operating system.

Protected applies during dragenter, dragover and dragleave. The store still holds the full payload, but the browser deliberately blinds script to it: dataTransfer.files is an empty FileList, getData() returns "", and items[i].getAsFile() returns null. What survives is metadata — dataTransfer.types (an array containing the literal string "Files" when OS files are in flight), items.length, and each item’s kind and type. This is a privacy measure: a page must not be able to read the contents of your home directory just because you dragged something across it without releasing.

Read-only applies during drop and dragend. Mutation is a no-op, but everything is readable. This is the only 20-or-so milliseconds in which files, getAsFile() and webkitGetAsEntry() do useful work.

The practical consequence is that a drop zone can only do coarse validation during the hover. You can tell that files are coming and, on Chromium and Firefox, you can read each item’s MIME type well enough to grey out the zone for a .dmg — but you cannot read a name, a size, or a byte. Everything precise happens after release.

DataTransfer surface availability across the three drag data store modes A matrix listing DataTransfer properties down the left and the dragstart, dragover and drop modes across the top, showing which reads succeed in each mode. What DataTransfer exposes, mode by mode Property or call on the DataTransfer dragstart read/write dragover protected drop read-only types readable readable readable items.length readable readable readable items[i].kind readable readable readable items[i].type readable readable readable items[i].getAsFile() File null File files as added always empty populated setData() / clearData() allowed no-op no-op Hover-time validation can only use types, kind and type — names, sizes and bytes arrive at drop.
Protected mode is why a hover preview cannot show file names: only the shape of the payload is visible until release.

Negotiating the drop operation

Two properties decide what the cursor shows and whether the drop is legal. The source sets effectAllowed during dragstart; the target sets dropEffect during dragover. The browser intersects them: if the target’s dropEffect is not permitted by the source’s effectAllowed, the operation collapses to none and no drop occurs.

For OS file drags the browser sets effectAllowed to "all" on your behalf, so any dropEffect you pick is honoured. Set it to "copy" explicitly anyway — leaving it unset lets platform modifier keys change it, and a user holding Shift on Windows can turn your upload into a "move" and get an OS-level “cannot move this item” dialog. Set dropEffect on every dragover tick, not once on dragenter: the drag loop resets it each iteration, and a value assigned during dragenter is discarded before the next frame.

Step-by-step implementation

The five blocks below form one module, dropzone.ts, in order. Each references only symbols defined earlier in this section.

Step 1: Mark up the zone around a real file input

Never build a drop zone out of a bare <div>. Wrap a hidden <input type="file"> so keyboard and screen-reader users can still choose files, and so a click anywhere on the zone opens the native picker with no JavaScript forwarding at all.

<label id="dropzone" class="dropzone" tabindex="0">
  <span class="dropzone__hint">
    Drag files here, paste, or <span class="dropzone__link">browse</span>
  </span>
  <input id="fileInput" type="file" multiple accept="image/*,application/pdf" hidden />
  <ul id="fileList" class="dropzone__list" aria-live="polite"></ul>
</label>

Because the <input> is a descendant of the <label>, the label’s implicit activation behaviour forwards clicks to it for free. aria-live="polite" on the list means each appended <li> is announced without stealing focus. The accept attribute filters the picker only — it is a convenience, not a control, and the reasons are spelled out in restricting uploads with the accept attribute.

Step 2: Track hover state with a depth counter

The naive dragleave handler removes the highlight every time the pointer crosses into a child element, so the zone strobes as the user moves over the hint text and the file list. Checking event.relatedTarget fixes most of it, but relatedTarget is null whenever the pointer leaves toward another document — including out of an iframe or off the window — and Safari has historically reported null more often than that. A depth counter is engine-independent.

const zone = document.getElementById("dropzone") as HTMLLabelElement;
const input = document.getElementById("fileInput") as HTMLInputElement;

/** Net dragenter-minus-dragleave depth. Zero means the pointer really left. */
let dragDepth = 0;

function cancel(event: Event): void {
  event.preventDefault();
  event.stopPropagation();
}

/** True when the drag carries OS files rather than page text or a link. */
function carriesFiles(dt: DataTransfer | null): boolean {
  return dt !== null && Array.prototype.includes.call(dt.types, "Files");
}

zone.addEventListener("dragenter", (event: DragEvent) => {
  cancel(event);
  if (!carriesFiles(event.dataTransfer)) return;
  dragDepth += 1;
  zone.classList.add("dropzone--active");
});

// Must run on EVERY tick: the drag loop resets the operation to "none" each time.
zone.addEventListener("dragover", (event: DragEvent) => {
  cancel(event);
  if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
});

zone.addEventListener("dragleave", (event: DragEvent) => {
  cancel(event);
  dragDepth = Math.max(0, dragDepth - 1);
  if (dragDepth === 0) zone.classList.remove("dropzone--active");
});

dt.types is a readonly string[] in the DOM lib but a live DOMStringList in older engines, which is why the call goes through Array.prototype.includes rather than dt.types.includes. The carriesFiles guard stops the zone lighting up when someone drags a paragraph of selected text across it.

Step 3: Stop stray drops from navigating the page

A user who misses the zone by twenty pixels drops onto the document, and the browser happily replaces your single-page application with the raw file. Every drop zone needs a window-level backstop.

// Anything dropped outside the zone is swallowed rather than navigated to.
window.addEventListener("dragover", (event: DragEvent) => {
  event.preventDefault();
  if (event.dataTransfer) event.dataTransfer.dropEffect = "none";
});

window.addEventListener("drop", (event: DragEvent) => {
  event.preventDefault();
});

The zone’s own handlers call stopPropagation(), so a legitimate drop never reaches these listeners. Setting dropEffect = "none" at the window level also gives the user a correct “no entry” cursor everywhere except the zone, which is a real usability gain on a busy page.

Step 4: Route each dropped item synchronously

DataTransferItemList is only valid for the duration of the event handler’s synchronous execution. The moment you await anything, the browser neuters the list and every subsequent getAsFile() or webkitGetAsEntry() returns null. So the drop handler does one pass with no await in it, capturing everything it needs, and hands the captured values to an async worker afterwards.

Decision tree for turning one dropped DataTransferItem into a usable File A branching tree starting from each dropped item, splitting on kind string versus file, then on directory versus file entry, and ending in reject or accept outcomes. Routing one dropped item to a usable File for each item switch on item.kind kind is string kind is file text/uri-list a URL, zero bytes text/plain, text/html ignore the item Fetch it server-side CORS blocks the browser webkitGetAsEntry() entry.isDirectory ? Directory walk it recursively getAsFile() one File object size 0 and empty type reject as a phantom
Four different payloads arrive through the same event; only one branch produces bytes you can upload.
export interface DroppedEntry {
  file: File;
  /** Relative path when the file came from a dropped folder, else the bare name. */
  path: string;
}

export interface DropCapture {
  files: File[];
  directories: FileSystemDirectoryEntry[];
  urls: string[];
}

/** Runs synchronously inside the drop handler — no await anywhere in this function. */
function captureDrop(dt: DataTransfer): DropCapture {
  const capture: DropCapture = { files: [], directories: [], urls: [] };

  for (const item of Array.from(dt.items)) {
    if (item.kind === "string") {
      if (item.type === "text/uri-list") {
        // getAsString is callback-based but registers synchronously, so it is safe here.
        item.getAsString((value) => capture.urls.push(value.trim()));
      }
      continue;
    }
    // kind === "file". Ask for the entry FIRST: it is the only way to see a directory.
    const entry = item.webkitGetAsEntry();
    if (entry !== null && entry.isDirectory) {
      capture.directories.push(entry as FileSystemDirectoryEntry);
      continue;
    }
    const file = item.getAsFile();
    if (file !== null) capture.files.push(file);
  }

  // Fallback for engines that returned nothing useful from the item list.
  if (capture.files.length === 0 && capture.directories.length === 0) {
    capture.files.push(...Array.from(dt.files));
  }
  return capture;
}

Reading webkitGetAsEntry() before getAsFile() is deliberate. A dropped folder yields a File-shaped object with size === 0 and type === ""; the entry is the only signal that distinguishes it from a genuinely empty file. Recursive traversal of capture.directories is a topic of its own, with a reader-per-directory loop and a hard depth cap — see handling dropped folders with the DataTransfer API.

Step 5: Validate, batch, and yield to the main thread

A drop of 400 photos from a camera card is routine. Iterating them synchronously and building a DOM node per file blocks the main thread long enough for Chrome to paint a beach ball; yielding every 25 items keeps interaction responsive at a cost of a few milliseconds total.

const MAX_BYTES = 100 * 1024 * 1024; // 100 MB per file
const ACCEPTED = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]);
const BATCH_SIZE = 25;

export type Rejection = { file: File; reason: string };

function screen(file: File): Rejection | null {
  if (file.size === 0 && file.type === "") {
    return { file, reason: "phantom entry — a folder or an interrupted copy" };
  }
  if (file.size > MAX_BYTES) {
    const mb = (file.size / 1_048_576).toFixed(1);
    return { file, reason: `${mb} MB exceeds the 100.0 MB cap` };
  }
  // file.type is advisory. The origin re-checks the bytes before it trusts anything.
  if (file.type !== "" && !ACCEPTED.has(file.type)) {
    return { file, reason: `type ${file.type} is not accepted` };
  }
  return null;
}

const list = document.getElementById("fileList") as HTMLUListElement;

export async function ingest(files: readonly File[]): Promise<File[]> {
  const accepted: File[] = [];
  const rejected: Rejection[] = [];

  for (let i = 0; i < files.length; i += 1) {
    const file = files[i];
    const problem = screen(file);
    if (problem === null) accepted.push(file);
    else rejected.push(problem);

    const row = document.createElement("li");
    row.textContent = problem === null
      ? `${file.name}${(file.size / 1024).toFixed(1)} KB`
      : `${file.name} — rejected: ${problem.reason}`;
    row.dataset.state = problem === null ? "ok" : "error";
    list.append(row);

    // Hand the main thread back every BATCH_SIZE items so the tab stays interactive.
    if ((i + 1) % BATCH_SIZE === 0) {
      await new Promise<void>((resolve) => setTimeout(resolve, 0));
    }
  }

  console.info(`[dropzone] accepted ${accepted.length}, rejected ${rejected.length}`);
  for (const r of rejected) console.warn(`[dropzone] ${r.file.name}: ${r.reason}`);
  return accepted;
}

Client-side screening is a fast path for user feedback, never a security boundary — file.type is derived from the extension on most platforms and is trivially wrong or absent. The authoritative check belongs on your origin, using the techniques in server-side file validation, and if you want a first-pass sniff before the bytes leave the browser, read detecting file type from magic bytes in JavaScript.

Step 6: Wire the drop, the picker, the clipboard and the keyboard

Four acquisition paths converge on one function, which is the whole point of normalising to File[] first.

zone.addEventListener("drop", (event: DragEvent) => {
  cancel(event);
  dragDepth = 0;
  zone.classList.remove("dropzone--active");
  if (!event.dataTransfer) return;

  const capture = captureDrop(event.dataTransfer);
  if (capture.directories.length > 0) {
    console.info(`[dropzone] ${capture.directories.length} folder(s) need traversal`);
  }
  if (capture.urls.length > 0) {
    console.info(`[dropzone] ${capture.urls.length} URL(s) — send to the server to fetch`);
  }
  void ingest(capture.files);
});

// The native picker: keyboard, click, and mobile "choose file" all land here.
input.addEventListener("change", () => {
  if (input.files) void ingest(Array.from(input.files));
  input.value = ""; // reselecting the same file must fire change again
});

// Paste: clipboardData.files has the same shape as DataTransfer.files.
document.addEventListener("paste", (event: ClipboardEvent) => {
  const data = event.clipboardData;
  if (!data || data.files.length === 0) return;
  event.preventDefault();
  void ingest(Array.from(data.files));
});

// A <label> is not reliably activated by Enter or Space when it has tabindex.
zone.addEventListener("keydown", (event: KeyboardEvent) => {
  if (event.key === "Enter" || event.key === " ") {
    event.preventDefault();
    input.click();
  }
});

Dropping two JPEGs and one folder logs:

[dropzone] 1 folder(s) need traversal
[dropzone] accepted 2, rejected 0

Pasting a screenshot logs [dropzone] accepted 1, rejected 0 and appends a row named image.png — every browser names clipboard bitmaps identically, which matters if your storage keys are derived from the filename. The naming rule, the image/png re-encode and the async Clipboard API alternative are covered in pasting images from the clipboard into an upload form.

Configuration reference

Option Type Default Effect
input.multiple boolean false Allows selecting or dropping more than one file. Without it, a multi-file drop still delivers every file to DataTransfer.files — the attribute only constrains the picker.
input.accept string "" Comma-separated MIME types or extensions. Filters the native picker; does not filter drops and does not validate.
input.webkitdirectory boolean false Turns the picker into a folder chooser and populates webkitRelativePath on each File. Unrelated to dropped folders.
dataTransfer.dropEffect "copy" | "move" | "link" | "none" "none" each tick Cursor feedback and the operation reported to the OS. Must be reassigned on every dragover.
dataTransfer.effectAllowed string "uninitialized" Set by the source on dragstart; "all" for OS file drags. Intersected with dropEffect to decide legality.
dataTransfer.types readonly string[] [] Contains the literal "Files" when OS files are in flight, plus MIME types for string items. Readable during the drag.
item.kind "file" | "string" The only reliable hover-time discriminator between a file drag and a text or link drag.
tabindex on the zone integer absent 0 puts the zone in the tab order. Required if the zone is a <div>; harmless and useful on a <label>.
aria-live on the list "polite" | "assertive" absent "polite" announces appended rows after the current utterance; "assertive" interrupts, which is wrong for a file list.
pointer-events: none on children CSS auto An alternative to the depth counter — children stop generating dragleave. Breaks any interactive control inside the zone.

Drop effect negotiation

Source effectAllowed Target sets dropEffect Result
all (OS file drag) copy Copy cursor, drop fires. This is the upload case.
all none No-entry cursor, drop never fires. Use for the window backstop.
copyMove link Not in the allowed set — collapses to none, no drop.
uninitialized anything Treated as all; every effect is permitted.
any unset by you Platform default, changeable by the user’s modifier keys. Never rely on it.

Edge cases and gotchas

The dragover listener must not be passive

Chrome makes touchstart and wheel listeners passive by default, and developers sometimes apply { passive: true } uniformly through a helper. Do that to dragover and the console prints Unable to preventDefault inside passive event listener invocation. on every tick while drop silently never fires. If you have a global addEventListener wrapper, exclude the four drag events explicitly.

dragleave fires on every internal boundary

The pointer crossing from the hint text to the file list produces a dragleave on the hint and a dragenter on the list, both bubbling to the zone. Removing the highlight on any dragleave makes it strobe at pointer-move frequency. The depth counter in Step 2 is the fix; resetting dragDepth = 0 inside the drop handler is what keeps it from drifting after a drop that swallowed the final dragleave.

The item list is neutered after the handler returns

This is the failure that costs the most debugging time, because it only appears once you add an await:

// BROKEN: every getAsFile() after the first await returns null.
zone.addEventListener("drop", async (event: DragEvent) => {
  event.preventDefault();
  await new Promise<void>((resolve) => setTimeout(resolve, 0));
  const items = Array.from(event.dataTransfer?.items ?? []);
  console.log(items.map((i) => i.getAsFile())); // [null, null]
});

Capture first, await second — exactly the split between captureDrop and ingest above. The same rule applies to webkitGetAsEntry(): the FileSystemEntry objects it returns stay valid after the handler exits, but the call itself must happen inside it.

A dropped folder arrives as a zero-byte phantom

DataTransfer.files contains one entry per dropped folder with size === 0, type === "", and the folder’s name. Ship that to your API and you get a successful upload of nothing, or a 400 from a validator that rejects empty bodies. The screen() guard rejects the shape, and webkitGetAsEntry().isDirectory identifies it positively. Note the ambiguity: a genuinely empty .txt file also has size 0 with an empty type on Linux, so use the entry check, not the heuristic, when correctness matters.

Users drag images from other tabs, not only from disk

Dragging an image out of a web page produces kind === "string" items with types text/uri-list and text/html — no bytes. Chromium sometimes adds a Files entry when the image is already in the disk cache, and sometimes does not, so you cannot depend on it. The correct handling is the capture.urls branch: post the URL to your API and fetch it there, where CORS does not apply. Fetching it in the browser fails with Access to fetch at 'https://example.com/photo.jpg' from origin 'https://your.app' has been blocked by CORS policy for the overwhelming majority of hosts.

Very large drops need backpressure, not just batching

Yielding every 25 items keeps the UI alive, but 400 files at 8 MB each is 3.2 GB of File handles queued for upload. File objects are lazy references to disk, so holding them is cheap — reading them is not. Cap concurrency when you hand off to the network, and read the sizing arguments in handling large file size limits before you pick a number. If a user can plausibly drop a gigabyte, wire an abort path too, following aborting uploads with AbortController and timeouts.

Shadow DOM retargets relatedTarget

If your zone lives inside a shadow root, event.relatedTarget is retargeted to the shadow host for anything outside the tree, so zone.contains(relatedTarget) returns false while the pointer is still visually inside. The depth counter is unaffected because it counts events rather than inspecting geometry — another reason to prefer it.

An empty zone with zero height cannot be entered

A drop zone styled with only padding on an empty flex child can collapse to a 0×0 box, and the dragenter event never fires because there is nothing under the pointer. Give the zone an explicit min-height and test it in its empty state, not only after the first upload has added rows.

Verification

Instrument the events in DevTools

Paste this into the console with the page open. It logs the state of every drag event without touching your server.

const z = document.getElementById("dropzone");
if (z) {
  for (const name of ["dragenter", "dragover", "dragleave", "drop"] as const) {
    z.addEventListener(name, (e) => {
      const dt = (e as DragEvent).dataTransfer;
      console.log(name, {
        prevented: e.defaultPrevented,
        types: dt ? Array.from(dt.types) : [],
        items: dt ? dt.items.length : 0,
        files: dt ? dt.files.length : 0,
        effect: dt ? dt.dropEffect : "n/a",
      });
    }, true);
  }
  console.info("[probe] listening on #dropzone");
}

Drag a file across the zone and release. A healthy zone shows dragover with prevented: true, types: ["Files"], items: 1, files: 0 and effect: "copy", then drop with files: 1. If drop never logs, dragover is not being cancelled. If dragover logs prevented: false, a passive listener or an early return is skipping the cancellation.

Drive a synthetic drop from a test

Chromium and Firefox implement the DataTransfer constructor, so you can build a drop entirely in script — useful in Playwright or in any browser-backed test runner. Note that jsdom does not implement DataTransfer, so this must run in a real browser.

export function synthesiseDrop(target: Element, files: readonly File[]): boolean {
  const dt = new DataTransfer();
  for (const file of files) dt.items.add(file);

  const over = new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer: dt });
  const overHandled = !target.dispatchEvent(over);

  const drop = new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: dt });
  const dropHandled = !target.dispatchEvent(drop);

  return overHandled && dropHandled;
}

const sample = new File([new Uint8Array([0xff, 0xd8, 0xff, 0xe0])], "probe.jpg", {
  type: "image/jpeg",
});
const node = document.getElementById("dropzone");
if (node) {
  console.assert(synthesiseDrop(node, [sample]), "dropzone did not cancel dragover and drop");
}

dispatchEvent returns false when a listener called preventDefault(), so the two negations are the assertion: both events must have been cancelled. Follow it with a DOM check that #fileList gained a row with data-state="ok". For a full end-to-end pass, add the uploading files with fetch and FormData step and assert on the request body.

Frequently Asked Questions

Why does my dropped file open in the browser instead of triggering my handler?

Your dragover handler is missing event.preventDefault(), or it is registered as a passive listener so the call is ignored. The drag loop resets the current operation to none on every tick and only keeps it if dragover is cancelled; with the operation at none the browser dispatches dragleave on release and performs its default action, which is navigating to the file.

Can I show the file name or reject a bad type while the user is still hovering?

Not the name, size, or bytes — during dragenter, dragover and dragleave the drag data store is in protected mode, so files is empty and getAsFile() returns null. You can read dataTransfer.types to confirm files are in flight and items[i].kind plus items[i].type to grey the zone out for an obviously wrong MIME type, but every precise check waits for drop.

Why do my getAsFile() calls return null when the handler is async?

The DataTransferItemList is only valid during the handler’s synchronous run; the first await neuters it. Do one synchronous pass that collects File objects and FileSystemEntry handles into your own arrays, then do the asynchronous work on those captured values.

Can keyboard-only users use a drag-and-drop zone?

Not the drag gesture itself, which is why an accessible implementation wraps a real <input type="file">. Make the zone a <label> so clicks forward to the input for free, give it tabindex="0", forward Enter and Space to input.click(), and announce each accepted file through an aria-live="polite" region.

Why does the highlight flicker as I move across the zone?

dragleave fires every time the pointer crosses into a child element, and relatedTarget is null in enough cases that geometry checks are unreliable. Keep a counter that increments on dragenter and decrements on dragleave, remove the highlight only when it reaches zero, and reset it to zero inside the drop handler.