Skip to content

Drag-and-Drop File Uploads in PHP

A drop zone is four event handlers and one counter-intuitive rule about preventDefault. The interesting parts are what comes after: folder drops, pasting from the clipboard, and making sure the whole thing still works without a mouse.

The minimum that works

<div id="zone" tabindex="0" role="button"
     aria-label="Drop files here, or press Enter to browse">
  Drop files here, or <button type="button" id="browse">browse</button>
</div>
<input type="file" id="picker" multiple hidden>
const zone   = document.getElementById('zone');
const picker = document.getElementById('picker');

['dragenter', 'dragover', 'dragleave', 'drop'].forEach(type =>
  zone.addEventListener(type, e => { e.preventDefault(); e.stopPropagation(); })
);

zone.addEventListener('dragover',  () => zone.classList.add('is-over'));
zone.addEventListener('dragleave', () => zone.classList.remove('is-over'));

zone.addEventListener('drop', (e) => {
  zone.classList.remove('is-over');
  handleFiles(e.dataTransfer.files);
});

// keyboard and click both reach the same file picker
zone.addEventListener('click', () => picker.click());
zone.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); picker.click(); }
});
picker.addEventListener('change', () => handleFiles(picker.files));

The rule that trips everyone up: you must call preventDefault() on dragover, not only on drop. Without it the browser's default action wins and it simply navigates to the file — your page disappears and the image opens instead. It reads like a no-op, and it is the whole thing.

Add a document-level guard too, so a near-miss outside the zone does not replace the page:

['dragover', 'drop'].forEach(type =>
  document.addEventListener(type, e => e.preventDefault())
);

dragleave fires more than you expect

Moving the pointer over a child element fires dragleave on the parent, so the highlight flickers. Count enters and leaves instead of toggling:

let depth = 0;
zone.addEventListener('dragenter', () => { if (++depth === 1) zone.classList.add('is-over'); });
zone.addEventListener('dragleave', () => { if (--depth === 0) zone.classList.remove('is-over'); });
zone.addEventListener('drop',      () => { depth = 0; zone.classList.remove('is-over'); });

Accepting dropped folders

dataTransfer.files ignores directories — drop a folder and you get nothing, with no error. Walking it needs the entries API:

async function filesFromDrop(dataTransfer) {
  const out = [];

  async function walk(entry, path = '') {
    if (entry.isFile) {
      const file = await new Promise((res, rej) => entry.file(res, rej));
      file.relativePath = path + file.name;
      out.push(file);
    } else if (entry.isDirectory) {
      const reader = entry.createReader();
      // readEntries returns at most 100 per call - keep going until empty
      while (true) {
        const batch = await new Promise((res, rej) => reader.readEntries(res, rej));
        if (!batch.length) break;
        for (const child of batch) await walk(child, path + entry.name + '/');
      }
    }
  }

  const roots = [...dataTransfer.items]
    .map(item => item.webkitGetAsEntry?.())
    .filter(Boolean);

  if (!roots.length) return [...dataTransfer.files];   // fallback
  await Promise.all(roots.map(r => walk(r)));
  return out;
}

The 100-entry batching in readEntries is a real trap: read it once and a folder of 500 files quietly yields 100. Loop until it returns an empty array.

Paste to upload

Screenshots go to the clipboard, and users increasingly expect to paste them straight in. It is one handler:

document.addEventListener('paste', (e) => {
  const files = [...e.clipboardData.items]
    .filter(item => item.kind === 'file')
    .map(item => item.getAsFile())
    .filter(Boolean);
  if (files.length) handleFiles(files);
});

Pasted images arrive named image.png every time, so generate your own stored name — which you should be doing anyway, per the security checklist.

Validate before sending

The advantage of JavaScript in the path is that you can reject a file before spending bandwidth on it:

const MAX = 10 * 1024 * 1024;
const OK  = ['image/jpeg', 'image/png', 'application/pdf'];

function handleFiles(list) {
  const accepted = [], rejected = [];
  for (const f of list) {
    if (!OK.includes(f.type))  rejected.push([f.name, 'type not allowed']);
    else if (f.size > MAX)     rejected.push([f.name, 'larger than 10 MB']);
    else                       accepted.push(f);
  }
  showRejected(rejected);
  if (accepted.length) upload(accepted);
}

This is user experience, not security. File.type comes from the operating system's guess at the extension and is as forgeable as anything else from the client — the server repeats every check. Note also that type is often an empty string for unusual extensions, so a strict allow-list will reject files a user considers valid; fall back to checking the extension too.

Sending to PHP

Once you have a list of File objects, the transfer is the same FormData + XMLHttpRequest pattern from the progress bar article:

function upload(files) {
  const body = new FormData();
  for (const f of files) body.append('files[]', f, f.relativePath || f.name);

  const xhr = new XMLHttpRequest();
  xhr.open('POST', '/upload.php');
  xhr.upload.onprogress = e => {
    if (e.lengthComputable) setPercent((e.loaded / e.total) * 100);
  };
  xhr.onload = () => render(JSON.parse(xhr.responseText));
  xhr.send(body);
}

If you pass a relative path as the third argument, treat it as untrusted data server-side: it is a client-supplied string and may contain ../. Recreate directories from a sanitised version, or ignore the path entirely and store flat.

Do not lose the keyboard

A drop zone that only responds to dragging excludes anyone using a keyboard, a screen reader, or a touch device — where dragging a file is not a gesture that exists. The markup at the top of this article covers it: a real <button> inside the zone, tabindex="0" and Enter/Space handling, and an aria-label that states both options. Announce results too, so a screen-reader user learns the upload finished:

<p id="live" role="status" aria-live="polite"></p>
document.getElementById('live').textContent = `${files.length} file(s) uploaded.`;

Keep the drag styling honest as well: is-over should change more than colour alone — a border style or shadow shift — so the state is visible to users who cannot distinguish the hue.

Skipping the plumbing

PHP File Uploader ships this behaviour: drop zone, file picker fallback, per-file validation before transfer, progress and cancellation, with the keyboard path already wired up.

<?php
$uploader = new PhpUploader();
$uploader->MultipleFilesUpload = true;
$uploader->InsertText = "Drop files here or click to browse";
$uploader->AllowedFileExtensions = "jpg,png,pdf";
$uploader->Render();
?>

See the multiple-file demo, or the custom UI demo if you want your own drop-zone markup driving it.