A drag and drop file upload lets people drop files onto a box instead of hunting through a file dialog. The trap is building only the drop zone. That shuts out keyboard and screen reader users, and on a phone there is nothing to drag at all. The reliable way is to keep a real file input underneath and treat dragging as an extra.

This tutorial builds that: a drop zone that is also a working file picker, image previews, checks for size and type, and a Remove button for each file.

What you will build#

A form with a dashed drop zone. Drop photos on it, or click it to open the file dialog. Each photo appears in a list with a thumbnail, its name and its size. Files that are not images, are over 2 MB, or push the list past five photos are skipped with a message. In the demo nothing is uploaded.

The frame is small, so open the demo in its own tab to try it at full size.

The markup#

<!-- In the <head>: <link rel="stylesheet" href="/style.css"> -->
<form id="upload" class="form">
  <label class="zone" id="zone">
    <input class="file" id="file" name="photos" type="file" accept="image/*" multiple>
    <span class="zone-title">Drop photos here, or <span class="link">choose files</span></span>
    <span class="zone-hint">Images only, up to 5 files, 2 MB each.</span>
  </label>

  <p class="status" id="status" role="status">No photos selected.</p>
  <ul class="errors" id="errors"></ul>
  <ul class="files" id="files"></ul>

  <button class="submit" type="submit">Upload photos</button>
</form>

<script src="/script.js" defer></script>

The drop zone is a label that contains the real input type="file". That gives you three things without writing any code: a click on the zone opens the file dialog, the zone is named for screen readers by its own text, and the input is reachable with the Tab key. On a phone, tapping the zone opens the picker, so touch users are not left out.

accept="image/*" tells the file dialog to show images, and multiple allows several files. Treat both as hints only. As you will see below, a dropped file ignores accept completely.

The JavaScript#

const form = document.querySelector("#upload");
const zone = document.querySelector("#zone");
const input = document.querySelector("#file");
const status = document.querySelector("#status");
const errors = document.querySelector("#errors");
const list = document.querySelector("#files");

const MAX_FILES = 5;
const MAX_SIZE = 2 * 1024 * 1024; // 2 MB

let selected = []; // [{ file, url }]
let dragDepth = 0;

function formatSize(bytes) {
  return bytes < 1024 * 1024
    ? `${Math.max(1, Math.round(bytes / 1024))} KB`
    : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}

// Keep the real input in sync, so a normal form submit sends exactly these files.
function syncInput() {
  const transfer = new DataTransfer();
  selected.forEach(({ file }) => transfer.items.add(file));
  input.files = transfer.files;
}

function render() {
  list.replaceChildren(
    ...selected.map(({ file, url }, index) => {
      const li = document.createElement("li");
      li.className = "file-item";
      li.innerHTML = `<img class="thumb" alt=""><span class="meta"><span class="name"></span><span class="size"></span></span><button class="remove" type="button">Remove</button>`;
      li.querySelector(".thumb").src = url;
      li.querySelector(".name").textContent = file.name;
      li.querySelector(".size").textContent = formatSize(file.size);
      const button = li.querySelector(".remove");
      button.setAttribute("aria-label", `Remove ${file.name}`);
      button.addEventListener("click", () => removeFile(index));
      return li;
    })
  );
  status.textContent = selected.length
    ? `${selected.length} ${selected.length === 1 ? "photo" : "photos"} selected.`
    : "No photos selected.";
}

function addFiles(files) {
  const problems = [];

  for (const file of files) {
    // "accept" only guides the file dialog. Dropped files skip it, so check here too.
    const duplicate = selected.some(
      (item) =>
        item.file.name === file.name &&
        item.file.size === file.size &&
        item.file.lastModified === file.lastModified
    );

    if (duplicate) {
      problems.push(`${file.name} was skipped: it is already in the list.`);
    } else if (!file.type.startsWith("image/")) {
      problems.push(`${file.name} was skipped: it is not an image.`);
    } else if (file.size > MAX_SIZE) {
      problems.push(`${file.name} was skipped: it is larger than 2 MB.`);
    } else if (selected.length >= MAX_FILES) {
      problems.push(`${file.name} was skipped: you can add up to ${MAX_FILES} photos.`);
    } else {
      selected.push({ file, url: URL.createObjectURL(file) });
    }
  }

  errors.replaceChildren(
    ...problems.map((text) => Object.assign(document.createElement("li"), { textContent: text }))
  );
  syncInput();
  render();
}

function removeFile(index) {
  URL.revokeObjectURL(selected[index].url); // free the memory held by the preview
  selected.splice(index, 1);
  errors.replaceChildren();
  syncInput();
  render();

  // Keep focus somewhere sensible after the removed row disappears.
  const next = list.querySelectorAll(".remove")[Math.min(index, selected.length - 1)];
  (next || input).focus();
}

// Choosing files in the dialog. The input then holds only the newly chosen files.
input.addEventListener("change", () => addFiles([...input.files]));

// Dragging files over the zone. A counter avoids flicker from child elements.
zone.addEventListener("dragenter", (event) => {
  event.preventDefault();
  dragDepth += 1;
  zone.classList.add("is-over");
});

zone.addEventListener("dragover", (event) => {
  event.preventDefault(); // required, or the browser will not allow a drop
  event.dataTransfer.dropEffect = "copy";
});

zone.addEventListener("dragleave", () => {
  dragDepth -= 1;
  if (dragDepth <= 0) zone.classList.remove("is-over");
});

zone.addEventListener("drop", (event) => {
  event.preventDefault();
  dragDepth = 0;
  zone.classList.remove("is-over");
  addFiles([...event.dataTransfer.files]);
});

// A drop that misses the zone should not make the browser open the file and leave the page.
["dragover", "drop"].forEach((type) =>
  window.addEventListener(type, (event) => event.preventDefault())
);

form.addEventListener("submit", (event) => {
  event.preventDefault(); // demo only: nothing is sent anywhere
  status.textContent = selected.length
    ? `Demo only: ${selected.length} ${selected.length === 1 ? "photo was" : "photos were"} not uploaded.`
    : "Choose at least one photo first.";
});

The script has a few parts that are worth reading one by one.

One list as the source of truth. The selected array holds every accepted file together with a preview address. Everything on the page is drawn from it, so adding, removing and drawing never disagree.

Keeping the input in sync. An input can be given a new set of files through a DataTransfer object. syncInput does that after every change, so the file input always contains exactly the files in the list. A normal form submit, or new FormData(form), then sends the right files, including the dropped ones and without the removed ones.

Checking the files. addFiles skips duplicates, files that are not images, files over 2 MB, and anything past the limit of five. Each skipped file gets a sentence in the errors list. The checks are needed because accept only guides the file dialog. A drop skips it, so a PDF can arrive in a zone marked "images only".

Previews without reading the file. URL.createObjectURL(file) makes a short address that points at the file in memory, so the img can show it right away. It has a cost: the browser keeps the file alive until you call URL.revokeObjectURL, which removeFile does.

The four drag events. dragenter and dragleave turn the highlight on and off. dragover must call preventDefault(), or the browser refuses to accept a drop. drop reads event.dataTransfer.files. The dragDepth counter exists because moving the pointer over a child element inside the zone fires a dragleave for the zone, and without the counter the highlight flickers.

Missed drops. The last block cancels dragover and drop on the whole window. If someone drops a file slightly outside the zone, the browser would otherwise open it in the tab and the person would lose the page.

Focus after removal. When a row disappears, the focused Remove button disappears with it, and a keyboard user would be thrown back to the top of the page. removeFile moves focus to the next Remove button, or to the input when the list is empty.

The CSS that matters#

.zone {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 1.8rem 1rem;
  border: 2px dashed #b9bfcc;
  border-radius: 12px;
  cursor: pointer;
}

.zone:hover,
.zone.is-over {
  border-color: #2f46c8;
}

.file {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  opacity: 0;
}

.zone:has(.file:focus-visible) {
  outline: 3px solid #2f46c8;
  outline-offset: 3px;
}

The input is hidden with opacity: 0 and a 1px size, not with display: none. A hidden-by-display input leaves the Tab order, and keyboard users could no longer reach the upload. The input is still there, still focusable, and the last rule uses :has() to draw the focus ring around the whole zone when the input has keyboard focus.

Sending the files#

async function send(event) {
  event.preventDefault();
  const response = await fetch("/api/upload", {
    method: "POST",
    body: new FormData(form),
  });
  if (!response.ok) throw new Error("Upload failed");
}

Because syncInput keeps the input in step with the list, new FormData(form) contains the right files. Do not set a Content-Type header yourself. The browser adds the correct one, with the boundary. fetch cannot report upload progress. If you need a progress bar, use XMLHttpRequest and its upload progress event.

Whatever you do in the browser, check the files again on the server. Size, type and the number of files must be enforced where nobody can edit the script.

Common problems and how to fix them#

The browser opens the dropped file instead of showing it#

dragover and drop were not cancelled. Call preventDefault() in both, on the zone and, as a safety net, on the window.

Dropping does nothing#

Almost always the missing preventDefault() in the dragover handler. Without it the zone is not a valid drop target.

The highlight flickers while dragging#

Child elements fire their own dragleave. Count dragenter and dragleave events, as dragDepth does, and remove the highlight only when the count returns to zero.

Wrong files get through#

accept is ignored on drop. Check file.type in the script. Keep in mind that the browser guesses the type from the file extension, so a renamed file can lie. The server should look at the real contents. Dropping a folder gives an entry with an empty type, which the script skips with a message.

The page gets heavier with every preview#

You forgot URL.revokeObjectURL. Revoke each address when its file is removed or the list is cleared.

Keyboard and screen reader users cannot use it#

The zone was built from a div with only mouse events. Keep the real input, keep the label, and draw a visible focus ring. The Remove buttons carry aria-label="Remove photo.png", so each one says which file it removes, and the status paragraph announces the new count.

Error messages are plain and generic#

Name the file and the reason, as the script does: "notes.pdf was skipped: it is not an image." For rules that belong to the form itself, such as "at least one photo is required", use the Constraint Validation API so the message is attached to the field.

Where it stops#

This version holds everything in memory and sends it in one request. For very large files, or flaky connections, you need chunked or resumable uploads, which is a job for a library and a server that supports it. When the upload finishes, say so with a short toast notification rather than replacing the form. If the form also has a message box next to the photos, a textarea character counter keeps that field honest too.