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
- A working drop handler that calls
preventDefault()ondragoveranddrop. - Access to the
DragEvent.dataTransfer.itemslist inside thedrophandler. - 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 onDataTransferItem, notDataTransfer.files. It returns aFileSystemEntrythat can be either a file or a directory β the only gateway to directory contents.- The
forloop overdataTransfer.itemsruns synchronously and first. Theitemslist is emptied once thedrophandler returns control to the browser, so you must grab every entry before the firstawait. readAllEntriesloops becausereadEntries()is capped at 100 results per invocation. A folder with 250 files needs three calls; stopping after the first silently drops files.entry.fullPathpreserves 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.
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.