Handling Dropped Folders with the DataTransfer API

To read the contents of a folder a user drags onto your page, call webkitGetAsEntry() on each DataTransferItem, then recursively walk the resulting FileSystemDirectoryEntry to flatten everything into a flat File[].

The plain DataTransfer.files list is no help here: when a directory is dropped it appears as a single zero-byte entry with the folder’s name, not its files. This is part of the drag-and-drop file uploads topic within upload fundamentals and browser APIs, and it is the one case where the file list alone fails you.

When to use this approach

  • Users drag entire folders (photo libraries, exported asset bundles, document sets) onto a drop zone.
  • You need every nested file, not just the top-level ones, preserving relative paths.
  • You target Chromium, Firefox, or Safari 15+, where the webkitGetAsEntry() directory API is supported.

Prerequisites

  1. A working drop handler that calls preventDefault() on dragover and drop.
  2. Access to the DragEvent.dataTransfer.items list inside the drop handler.
  3. TypeScript with lib: ["DOM", "ES2022"]; the FileSystem entry types are not in the standard DOM lib, so you declare minimal interfaces below.

Implementation

The directory-reader API is callback-based and the readEntries() call returns at most 100 entries per call, so you must keep calling it until it returns an empty batch. The code below promisifies both calls and recurses depth-first, building a path-prefixed File[].

// Minimal typings β€” the FileSystem entry API predates standard DOM lib types.
interface FsEntry {
  isFile: boolean;
  isDirectory: boolean;
  name: string;
  fullPath: string;
}
interface FsFileEntry extends FsEntry {
  isFile: true;
  file(success: (f: File) => void, error: (e: DOMException) => void): void;
}
interface FsDirEntry extends FsEntry {
  isDirectory: true;
  createReader(): FsDirReader;
}
interface FsDirReader {
  readEntries(
    success: (entries: FsEntry[]) => void,
    error: (e: DOMException) => void,
  ): void;
}

export interface DroppedFile {
  file: File;
  path: string; // relative path including folder name, e.g. "photos/2024/a.jpg"
}

function getFile(entry: FsFileEntry): Promise<File> {
  return new Promise((resolve, reject) => entry.file(resolve, reject));
}

// readEntries returns up to 100 items per call; loop until exhausted.
function readAllEntries(reader: FsDirReader): Promise<FsEntry[]> {
  return new Promise((resolve, reject) => {
    const all: FsEntry[] = [];
    const pump = (): void => {
      reader.readEntries((batch) => {
        if (batch.length === 0) {
          resolve(all);
        } else {
          all.push(...batch);
          pump();
        }
      }, reject);
    };
    pump();
  });
}

async function walkEntry(entry: FsEntry, out: DroppedFile[]): Promise<void> {
  if (entry.isFile) {
    const file = await getFile(entry as FsFileEntry);
    out.push({ file, path: entry.fullPath.replace(/^\//, "") });
  } else if (entry.isDirectory) {
    const reader = (entry as FsDirEntry).createReader();
    const children = await readAllEntries(reader);
    for (const child of children) {
      await walkEntry(child, out);
    }
  }
}

export async function flattenDrop(dataTransfer: DataTransfer): Promise<DroppedFile[]> {
  const out: DroppedFile[] = [];
  // Capture entries synchronously β€” items is cleared after the event handler returns.
  const entries: FsEntry[] = [];
  for (const item of Array.from(dataTransfer.items)) {
    if (item.kind !== "file") continue;
    const entry = (item as DataTransferItem & {
      webkitGetAsEntry?: () => FsEntry | null;
    }).webkitGetAsEntry?.();
    if (entry) entries.push(entry);
    else {
      const f = item.getAsFile();
      if (f) out.push({ file: f, path: f.name });
    }
  }
  for (const entry of entries) {
    await walkEntry(entry, out);
  }
  return out;
}

Wire it into the drop handler:

zone.addEventListener("drop", async (event: DragEvent) => {
  event.preventDefault();
  if (!event.dataTransfer) return;
  const dropped = await flattenDrop(event.dataTransfer);
  console.log(`[folder-drop] flattened ${dropped.length} file(s)`);
  for (const { path } of dropped) console.log("  ", path);
});

Line-by-line on the critical parts

  • webkitGetAsEntry() lives on DataTransferItem, not DataTransfer.files. It returns a FileSystemEntry that can be either a file or a directory β€” the only gateway to directory contents.
  • The for loop over dataTransfer.items runs synchronously and first. The items list is emptied once the drop handler returns control to the browser, so you must grab every entry before the first await.
  • readAllEntries loops because readEntries() is capped at 100 results per invocation. A folder with 250 files needs three calls; stopping after the first silently drops files.
  • entry.fullPath preserves the path relative to the drop, letting you reconstruct the folder structure server-side or in S3 key prefixes.

Once you have DroppedFile[], read each file exactly as you would any other β€” see reading files with FileReader and ArrayBuffer for streaming large entries without exhausting memory.

Recursive flattening of a dropped folder into a File array A directory entry expands into nested files and subfolders, each walked recursively and appended to a flat output array. webkitGetAsEntry recursion photos/ (dir) createReader() a.jpg (file) 2024/ (dir) recurse b.png (file) DroppedFile[] photos/a.jpg, photos/2024/b.png
Each directory entry is expanded with createReader() and walked depth-first; files accumulate into one flat array with their relative paths.

Configuration gotchas

readEntries() returns only the first 100 entries. Chromium caps a single readEntries() call at 100 items. Calling it once on a folder of 300 files silently returns 100. Fix: loop until a call returns an empty array, as readAllEntries does.

items is empty after the first await. The DataTransferItemList is neutered once the drop handler yields to the event loop. If you write await something(); item.webkitGetAsEntry() the call returns null. Fix: synchronously collect all entries before any await.

webkitGetAsEntry is undefined in older or non-supporting contexts. Calling it unguarded throws TypeError: item.webkitGetAsEntry is not a function. Fix: feature-detect with optional chaining and fall back to item.getAsFile() for a flat drop.

Symlink loops or huge trees stall the UI. A deeply nested tree blocks because traversal is awaited serially. Fix: cap depth, surface a count to the user, and process the resulting array in batches.

Testing / verification

Drop a folder and assert the flattened shape in the console:

const expectedExtensions = new Set(["jpg", "png", "pdf"]);
const dropped = await flattenDrop(event.dataTransfer!);
console.assert(dropped.length > 0, "no files extracted from folder");
for (const { path } of dropped) {
  const ext = path.split(".").pop()!.toLowerCase();
  console.assert(expectedExtensions.has(ext), `unexpected type: ${path}`);
  console.assert(path.includes("/"), `path lost folder prefix: ${path}`);
}
console.log("verified", dropped.length, "files with preserved paths");

FAQ

Why is my dropped folder showing up as a single empty file?

DataTransfer.files represents a directory as one zero-byte entry. You must use webkitGetAsEntry() on the DataTransferItem and recurse with createReader() to reach the files inside.

Why does my traversal miss files in large folders?

readEntries() returns at most 100 entries per call. Keep calling it until it yields an empty array, accumulating results β€” otherwise everything past the first 100 is dropped.

Why does webkitGetAsEntry() return null after I await something?

The DataTransferItemList is cleared once the drop handler returns to the event loop. Collect all entries synchronously at the top of the handler, then do your async traversal.

Does this work in Firefox and Safari?

Yes. webkitGetAsEntry and createReader are supported in current Chromium, Firefox, and Safari 15+. Keep the getAsFile() fallback for resilience and feature-detect before calling.