Drag and Drop Image Preview Before Upload in JavaScript – No Library

Need to show users which images they selected before they hit upload? This small component adds drag and drop, multiple image previews, file size checks and remove buttons using plain JavaScript.

No jQuery, no framework and nothing is uploaded while the preview is being created. The images stay inside the browser.

This is part of Copy, Run & Customize, a series of small projects you can open, change and use in your own work.

Most examples only handle one image or stop working once you try to remove a file. This one keeps the selected file list updated as well.

Copy the three blocks below, then open the TeknoFenomen Code Playground to run and edit it.

HTML

<main class="upload-page">
  <section class="upload-card">
    <header class="upload-header">
      <span class="eyebrow">IMAGE UPLOAD</span>
      <h1>Preview images before uploading</h1>
      <p>
        Drop your images below or choose them from your device.
        You can check and remove files before continuing.
      </p>
    </header>

    <label class="drop-zone" id="dropZone" for="fileInput">
      <input
        id="fileInput"
        type="file"
        accept="image/jpeg,image/png,image/webp,image/gif"
        multiple
      >

      <span class="upload-icon">↑</span>
      <strong>Drop images here</strong>
      <span>or click to choose files</span>

      <small>JPEG, PNG, WebP or GIF · Maximum 5 MB each</small>
    </label>

    <p class="status-message" id="statusMessage" role="status">
      You can select up to 6 images.
    </p>

    <section class="preview-section">
      <div class="preview-heading">
        <h2>
          Selected images
          <span id="fileCount">0</span>
        </h2>

        <button id="clearButton" type="button" disabled>
          Clear all
        </button>
      </div>

      <div class="preview-grid" id="previewGrid">
        <p class="empty-message">
          No images selected yet.
        </p>
      </div>
    </section>
  </section>
</main>

CSS

:root {
  color-scheme: dark;
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system,
    BlinkMacSystemFont, "Segoe UI", sans-serif;

  --background: #080b12;
  --panel: #111722;
  --panel-light: #171f2d;
  --border: #2a3445;
  --text: #edf1f7;
  --muted: #98a4b7;
  --accent: #7c5cff;
  --accent-light: #a99aff;
  --success: #45d692;
  --danger: #ff6d7c;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top, #1b2440 0, transparent 38%),
    var(--background);
  color: var(--text);
}

button,
input {
  font: inherit;
}

button {
  border: 1px solid var(--border);
  border-radius: 9px;
  background: var(--panel-light);
  color: var(--text);
  cursor: pointer;
}

button:hover:not(:disabled) {
  border-color: #57647a;
  background: #202a3b;
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.45;
}

.upload-page {
  width: min(920px, calc(100% - 30px));
  margin: 0 auto;
  padding: 48px 0;
}

.upload-card {
  padding: 28px;
  border: 1px solid var(--border);
  border-radius: 20px;
  background: rgba(17, 23, 34, 0.95);
  box-shadow: 0 25px 70px rgba(0, 0, 0, 0.35);
}

.upload-header {
  max-width: 650px;
  margin-bottom: 24px;
}

.eyebrow {
  color: var(--accent-light);
  font-size: 0.75rem;
  font-weight: 800;
  letter-spacing: 0.16em;
}

h1 {
  margin: 8px 0 10px;
  font-size: clamp(1.8rem, 6vw, 2.7rem);
  line-height: 1.1;
}

.upload-header p {
  margin: 0;
  color: var(--muted);
  line-height: 1.65;
}

.drop-zone {
  display: flex;
  min-height: 240px;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  gap: 8px;
  padding: 30px;
  border: 2px dashed #45516a;
  border-radius: 16px;
  background: #0c111b;
  text-align: center;
  cursor: pointer;
  transition:
    border-color 0.2s,
    background 0.2s,
    transform 0.2s;
}

.drop-zone:hover,
.drop-zone.dragging {
  border-color: var(--accent);
  background: rgba(124, 92, 255, 0.09);
}

.drop-zone.dragging {
  transform: scale(1.01);
}

.drop-zone input {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
}

.upload-icon {
  display: grid;
  width: 54px;
  height: 54px;
  place-items: center;
  margin-bottom: 5px;
  border-radius: 15px;
  background: var(--accent);
  color: white;
  font-size: 1.8rem;
  font-weight: 700;
}

.drop-zone strong {
  font-size: 1.08rem;
}

.drop-zone > span:not(.upload-icon),
.drop-zone small {
  color: var(--muted);
}

.drop-zone small {
  margin-top: 8px;
  font-size: 0.78rem;
}

.status-message {
  min-height: 24px;
  margin: 13px 2px 0;
  color: var(--muted);
  font-size: 0.88rem;
}

.status-message.success {
  color: var(--success);
}

.status-message.error {
  color: var(--danger);
}

.preview-section {
  margin-top: 25px;
}

.preview-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 13px;
}

.preview-heading h2 {
  margin: 0;
  font-size: 1rem;
}

.preview-heading h2 span {
  display: inline-grid;
  min-width: 25px;
  height: 25px;
  place-items: center;
  margin-left: 6px;
  border-radius: 20px;
  background: var(--panel-light);
  color: var(--muted);
  font-size: 0.78rem;
}

.preview-heading button {
  padding: 8px 13px;
  font-size: 0.84rem;
}

.preview-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 14px;
}

.empty-message {
  grid-column: 1 / -1;
  margin: 0;
  padding: 35px;
  border: 1px solid var(--border);
  border-radius: 13px;
  color: var(--muted);
  text-align: center;
}

.preview-card {
  position: relative;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: 13px;
  background: var(--panel-light);
}

.preview-card img {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  background: #090d14;
  object-fit: cover;
}

.file-details {
  min-width: 0;
  padding: 11px 12px 13px;
}

.file-name {
  display: block;
  overflow: hidden;
  margin-bottom: 4px;
  font-size: 0.86rem;
  font-weight: 700;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.file-size {
  color: var(--muted);
  font-size: 0.76rem;
}

.remove-button {
  position: absolute;
  top: 8px;
  right: 8px;
  display: grid;
  width: 31px;
  height: 31px;
  place-items: center;
  border-color: rgba(255, 255, 255, 0.25);
  border-radius: 50%;
  background: rgba(5, 8, 13, 0.8);
  color: white;
  font-size: 1.1rem;
  line-height: 1;
}

.remove-button:hover {
  border-color: var(--danger);
  background: var(--danger);
}

@media (max-width: 680px) {
  .upload-page {
    width: min(100% - 20px, 920px);
    padding: 20px 0;
  }

  .upload-card {
    padding: 18px;
  }

  .drop-zone {
    min-height: 210px;
    padding: 22px 15px;
  }

  .preview-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@media (max-width: 420px) {
  .preview-grid {
    grid-template-columns: 1fr;
  }
}

JavaScript

const fileInput = document.querySelector("#fileInput");
const dropZone = document.querySelector("#dropZone");
const previewGrid = document.querySelector("#previewGrid");
const fileCount = document.querySelector("#fileCount");
const clearButton = document.querySelector("#clearButton");
const statusMessage = document.querySelector("#statusMessage");

const maximumFiles = 6;
const maximumFileSize = 5 * 1024 * 1024;

const allowedTypes = new Set([
  "image/jpeg",
  "image/png",
  "image/webp",
  "image/gif"
]);

let selectedFiles = [];
let previewUrls = [];
let dragDepth = 0;

function formatFileSize(bytes) {
  if (bytes < 1024) {
    return `${bytes} bytes`;
  }

  if (bytes < 1024 * 1024) {
    return `${(bytes / 1024).toFixed(1)} KB`;
  }

  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function getFileKey(file) {
  return `${file.name}-${file.size}-${file.lastModified}`;
}

function showStatus(message, type = "") {
  statusMessage.textContent = message;
  statusMessage.className = `status-message ${type}`;
}

function syncFileInput() {
  try {
    const transfer = new DataTransfer();

    selectedFiles.forEach((file) => {
      transfer.items.add(file);
    });

    fileInput.files = transfer.files;
  } catch {
    // Previewing still works in browsers that don't allow
    // the FileList to be changed from JavaScript.
  }
}

function releasePreviewUrls() {
  previewUrls.forEach((url) => {
    URL.revokeObjectURL(url);
  });

  previewUrls = [];
}

function removeFile(index) {
  const removedFile = selectedFiles[index];

  selectedFiles.splice(index, 1);
  syncFileInput();
  renderPreviews();

  showStatus(`${removedFile.name} was removed.`);
}

function createPreviewCard(file, index) {
  const card = document.createElement("article");
  card.className = "preview-card";

  const imageUrl = URL.createObjectURL(file);
  previewUrls.push(imageUrl);

  const image = document.createElement("img");
  image.src = imageUrl;
  image.alt = "";

  const details = document.createElement("div");
  details.className = "file-details";

  const name = document.createElement("span");
  name.className = "file-name";
  name.textContent = file.name;
  name.title = file.name;

  const size = document.createElement("span");
  size.className = "file-size";
  size.textContent = formatFileSize(file.size);

  const removeButton = document.createElement("button");
  removeButton.className = "remove-button";
  removeButton.type = "button";
  removeButton.textContent = "×";
  removeButton.setAttribute(
    "aria-label",
    `Remove ${file.name}`
  );

  removeButton.addEventListener("click", () => {
    removeFile(index);
  });

  details.append(name, size);
  card.append(image, details, removeButton);

  return card;
}

function renderPreviews() {
  releasePreviewUrls();
  previewGrid.innerHTML = "";

  fileCount.textContent = selectedFiles.length;
  clearButton.disabled = selectedFiles.length === 0;

  if (selectedFiles.length === 0) {
    const emptyMessage = document.createElement("p");
    emptyMessage.className = "empty-message";
    emptyMessage.textContent = "No images selected yet.";

    previewGrid.append(emptyMessage);
    return;
  }

  selectedFiles.forEach((file, index) => {
    previewGrid.append(createPreviewCard(file, index));
  });
}

function addFiles(fileList) {
  const incomingFiles = Array.from(fileList);
  const existingKeys = new Set(
    selectedFiles.map(getFileKey)
  );

  const rejected = [];

  for (const file of incomingFiles) {
    if (selectedFiles.length >= maximumFiles) {
      rejected.push("The 6 image limit was reached.");
      break;
    }

    if (!allowedTypes.has(file.type)) {
      rejected.push(`${file.name} isn't a supported image.`);
      continue;
    }

    if (file.size > maximumFileSize) {
      rejected.push(`${file.name} is larger than 5 MB.`);
      continue;
    }

    const key = getFileKey(file);

    if (existingKeys.has(key)) {
      rejected.push(`${file.name} is already selected.`);
      continue;
    }

    selectedFiles.push(file);
    existingKeys.add(key);
  }

  syncFileInput();
  renderPreviews();

  if (rejected.length > 0) {
    showStatus(rejected[0], "error");
    return;
  }

  const count = incomingFiles.length;
  showStatus(
    `${count} ${count === 1 ? "image" : "images"} added.`,
    "success"
  );
}

fileInput.addEventListener("change", (event) => {
  addFiles(event.target.files);
});

dropZone.addEventListener("dragenter", (event) => {
  event.preventDefault();
  dragDepth += 1;
  dropZone.classList.add("dragging");
});

dropZone.addEventListener("dragover", (event) => {
  event.preventDefault();
});

dropZone.addEventListener("dragleave", (event) => {
  event.preventDefault();
  dragDepth -= 1;

  if (dragDepth <= 0) {
    dragDepth = 0;
    dropZone.classList.remove("dragging");
  }
});

dropZone.addEventListener("drop", (event) => {
  event.preventDefault();

  dragDepth = 0;
  dropZone.classList.remove("dragging");

  addFiles(event.dataTransfer.files);
});

clearButton.addEventListener("click", () => {
  selectedFiles = [];
  syncFileInput();
  renderPreviews();

  showStatus("All selected images were removed.");
});

window.addEventListener("beforeunload", releasePreviewUrls);

renderPreviews();

How it works

Both clicking the drop area and dragging files into it send the selected images to the same JavaScript function.

Before adding a file, the code checks:

  • Whether it is JPEG, PNG, WebP or GIF
  • Whether it is smaller than 5 MB
  • Whether the same file was already selected
  • Whether the six-image limit has been reached

The previews are created with URL.createObjectURL(). These temporary browser URLs are released again when the list changes, so they don’t sit in memory longer than needed.

This example only handles the selection and preview part. It doesn’t upload anything to a server because the upload endpoint will be different for every project.

@tuni_ai help us improve this! Tell us about any additional features you can recommend and that are needed, and provide us with the ready-made code.

Thinking

Read topic
Reading: Drag and Drop Image Preview Before Upload in JavaScript – No Library

Hello! I’m Tuni AI, Technoblok’s AI assistant.

That’s a really solid foundation for an image uploader—handling multiple files, previews, and drag-and-drop with plain JavaScript is a fantastic achievement!

Since you’ve asked for features to improve it, I reviewed the code and thought about its practical use case. Here are a few recommendations, focusing on robustness, user experience, and completion flow, along with rough code guideposts for implementation.

:sparkles: Feature Recommendations for Improvement

1. Enhanced File Validation and Error Feedback

While you already have size and type checks, they are currently displayed generically. A more granular system helps users debug their mistakes immediately.

  • Improvement: When a file exceeds the limit or is of an unsupported type, the error message should be attached directly to that file listing, not just the status message.
  • Code Idea: When iterating through File objects, if validation fails, instead of doing nothing, create a special error div inside the preview tile for that image