複数画像アップロード(Multiple Image Upload)— プレビュー・削除・枚数制限

フォーム入力 初級

このコンポーネントについて

商品画像の複数登録・申請書類の添付・投稿フォームのギャラリー添付など、1枚だけでなく複数枚の画像をまとめて扱いたい場面は業務アプリで頻繁に登場します。 このページでは、クリックでのファイル選択とドラッグ&ドロップの両方に対応し、選択した画像をサムネイルグリッドでプレビュー・個別削除できる複数画像アップロードの実装例を紹介します。

このデモはサーバーへの送信を行いません。選択した画像をブラウザ上でプレビュー・削除するところまでを体験できる擬似デモです。 枚数の上限(このデモでは5枚)を超えて選択・ドロップした場合は、新しいファイルを1枚も追加せずエラーメッセージを表示する、業務アプリでの安全な実装パターンも確認できます。

  • 複数ファイル選択 / ドラッグ&ドロップ — クリックでのファイル選択ダイアログ(multiple 属性)と、エリアへのドラッグ&ドロップの両方で複数画像をまとめて追加できる
  • サムネイルグリッド表示 — 選択した画像をグリッドレイアウトでプレビュー表示する
  • 個別削除 — 各サムネイルの×ボタンで、その画像だけを一覧・内部データの両方から削除できる
  • 選択枚数カウンター — 「n / 5枚選択中」の表示で、現在の選択数と上限が常に分かる
  • 枚数上限チェック — 上限を超える選択・ドロップは操作ごと拒否し、エラーメッセージを表示する(既存の選択は維持したまま、上限超過分だけをこっそり無視したり自動で差し替えたりしない)
  • 画像形式バリデーション — 画像以外のファイルは一覧に追加せず、除外した旨をメッセージで示す

実装のポイント・注意点

選択済みファイルの削除ボタンには、file.name ではなく追加のたびに発行する連番ID(fileIdCounter++)を紐付けます。 同名ファイルを複数選択した場合や、一度削除したファイルを再度選択した場合でも、削除ボタンが正しい1件だけを指せるようにするための設計です。

プレビュー表示には URL.createObjectURL(file) を使います。FileReader.readAsDataURL() と違ってBase64変換が不要なぶん軽量で、複数枚を同時に扱う本ページに向いています。 発行したオブジェクトURLは、画像を削除・リセットしたタイミングで必ず URL.revokeObjectURL() で解放し、メモリリークを防いでください。

枚数上限のチェックは「既存の選択数+新規の画像ファイル数」の合計で判定し、超える場合は新しいファイルを1枚も追加せず操作全体を取り消します。 一部だけ追加して残りを静かに無視すると、ユーザーが気づかないまま作業を続けてしまう事故につながるため、エラーメッセージで明確に知らせる設計にしています。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

0 / 5枚選択中

※ このデモはサーバーへの送信を行いません。選択した画像のプレビューと削除を体験できる擬似デモです。

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>複数画像アップロード</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<div class="img-multi">
  <label class="img-multi__area" id="multi-upload-area" for="multi-image-input">
    <svg class="img-multi__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
      fill="none" stroke="currentColor" stroke-width="1.5"
      stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
      <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
      <polyline points="17 8 12 3 7 8"/>
      <line x1="12" y1="3" x2="12" y2="15"/>
    </svg>
    <span class="img-multi__text">クリックまたはドラッグ&ドロップで画像を選択(最大5枚)</span>
    <span class="img-multi__sub">PNG / JPG / GIF / WebP</span>
    <input type="file" id="multi-image-input" accept="image/*" multiple class="img-multi__input">
  </label>

  <div class="img-multi__status">
    <p class="img-multi__count" id="multi-count">0 / 5枚選択中</p>
    <p class="img-multi__error" id="multi-error" hidden></p>
  </div>

  <div class="img-multi__grid" id="multi-grid"></div>

  <p class="img-multi__note">※ このデモはサーバーへの送信を行いません。選択した画像のプレビューと削除を体験できる擬似デモです。</p>
</div>

<script src="./script.js"></script>
</body>
</html>
/* 複数画像アップロード — style.css */
:root {
  --multi-border:       #C8D5E6;
  --multi-border-hover: #2B7FE8;
  --multi-bg:           #F7F9FC;
  --multi-bg-dragover:  #EBF2FF;
  --multi-icon-color:   #9AA5B4;
  --multi-text-color:   #5A6A7A;
  --multi-sub-color:    #9AA5B4;
  --multi-note-color:   #B0BAC8;
  --multi-danger-color: #EF4444;
}

*, *::before, *::after { box-sizing: border-box; }

body {
  font-family: sans-serif;
  padding: 32px 16px;
  background: #f8f9fa;
  display: flex;
  justify-content: center;
}

/* アップロードコンテナ */
.img-multi {
  width: 100%;
  max-width: 480px;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

/* ドロップエリア(labelをボタン代わりに使う) */
.img-multi__area {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 32px 24px;
  border: 2px dashed var(--multi-border);
  border-radius: 12px;
  background: var(--multi-bg);
  cursor: pointer;
  transition: border-color 0.15s, background 0.15s;
}

.img-multi__area:hover,
.img-multi__area.is-dragover {
  border-color: var(--multi-border-hover);
  background: var(--multi-bg-dragover);
}

.img-multi__icon {
  width: 36px;
  height: 36px;
  color: var(--multi-icon-color);
}

.img-multi__text {
  font-size: 14px;
  color: var(--multi-text-color);
  font-weight: 600;
  text-align: center;
}

.img-multi__sub {
  font-size: 12px;
  color: var(--multi-sub-color);
}

/* input は非表示にしてlabelでトリガーする */
.img-multi__input {
  display: none;
}

/* カウンター・エラー行 */
.img-multi__status {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
}

.img-multi__count {
  font-size: 13px;
  color: var(--multi-text-color);
  margin: 0;
}

.img-multi__error {
  font-size: 13px;
  color: var(--multi-danger-color);
  margin: 0;
}

/* hidden属性の打ち消し(pタグ自体はブロックだが明示しておく) */
.img-multi__error[hidden] {
  display: none;
}

/* サムネイルグリッド */
.img-multi__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
  gap: 12px;
}

.img-multi__item {
  position: relative;
}

.img-multi__thumb {
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: 6px;
  border: 1px solid var(--multi-border);
  display: block;
}

.img-multi__remove {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 22px;
  height: 22px;
  line-height: 20px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.55);
  color: #fff;
  font-size: 14px;
  cursor: pointer;
  transition: background 0.15s;
}

.img-multi__remove:hover {
  background: rgba(0, 0, 0, 0.75);
}

.img-multi__filename {
  margin: 4px 0 0;
  font-size: 11px;
  color: var(--multi-text-color);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* 擬似デモ注記 */
.img-multi__note {
  font-size: 12px;
  color: var(--multi-note-color);
  margin: 0;
  text-align: center;
}

/* リセットボタン */
.demo-controls {
  margin-top: 12px;
}

.reset-btn {
  padding: 6px 16px;
  font-size: 13px;
  color: #5A6A7A;
  background: #fff;
  border: 1.5px solid #D0D7E0;
  border-radius: 6px;
  cursor: pointer;
  font-family: sans-serif;
  transition: background 0.15s, border-color 0.15s;
}

.reset-btn:hover {
  background: #F4F6F9;
  border-color: #9AA5B4;
}

@media (max-width: 480px) {
  .img-multi__area { padding: 24px 16px; }
}
// 複数画像アップロード — script.js
var MAX_FILES = 5;

var uploadArea  = document.getElementById('multi-upload-area');
var imageInput  = document.getElementById('multi-image-input');
var countEl     = document.getElementById('multi-count');
var errorEl     = document.getElementById('multi-error');
var gridEl      = document.getElementById('multi-grid');

// 選択中のファイルを { id, file, url } の配列で管理する
var selectedFiles = [];
var fileIdCounter = 0;

// ファイル選択ダイアログから選んだとき
imageInput.addEventListener('change', function () {
  handleFiles(imageInput.files);
  imageInput.value = ''; // 同じファイルを連続選択してもchangeが発火するようにする
});

// ドラッグ中はエリアをハイライトする
uploadArea.addEventListener('dragover', function (e) {
  e.preventDefault();
  uploadArea.classList.add('is-dragover');
});

uploadArea.addEventListener('dragleave', function () {
  uploadArea.classList.remove('is-dragover');
});

// ドロップ時にファイルを受け取る
uploadArea.addEventListener('drop', function (e) {
  e.preventDefault();
  uploadArea.classList.remove('is-dragover');
  handleFiles(e.dataTransfer.files);
});

function handleFiles(fileList) {
  var files = Array.prototype.slice.call(fileList);

  // 画像以外のファイルを除外する
  var imageFiles = [];
  var excludedCount = 0;
  files.forEach(function (file) {
    if (file.type.indexOf('image/') === 0) {
      imageFiles.push(file);
    } else {
      excludedCount++;
    }
  });

  // 枚数上限を超える場合は新しいファイルを1枚も追加せず操作全体を拒否する
  if (selectedFiles.length + imageFiles.length > MAX_FILES) {
    showError('上限' + MAX_FILES + '枚を超えるため追加できませんでした(現在' + selectedFiles.length + '枚選択中)');
    return;
  }

  imageFiles.forEach(function (file) {
    selectedFiles.push({
      id: fileIdCounter++,
      file: file,
      url: URL.createObjectURL(file)
    });
  });

  if (excludedCount > 0) {
    showError('画像以外のファイルは' + excludedCount + '件除外されました');
  } else {
    hideError();
  }

  renderGrid();
  updateCount();
}

function renderGrid() {
  // 子要素をすべて削除してから作り直す(innerHTMLは使わない)
  while (gridEl.firstChild) {
    gridEl.removeChild(gridEl.firstChild);
  }

  selectedFiles.forEach(function (entry) {
    var item = document.createElement('div');
    item.className = 'img-multi__item';

    var thumb = document.createElement('img');
    thumb.className = 'img-multi__thumb';
    thumb.src = entry.url;
    thumb.alt = entry.file.name;

    var removeBtn = document.createElement('button');
    removeBtn.type = 'button';
    removeBtn.className = 'img-multi__remove';
    removeBtn.setAttribute('aria-label', '削除');
    removeBtn.textContent = '×';
    removeBtn.addEventListener('click', function () {
      removeFile(entry.id);
    });

    var filename = document.createElement('p');
    filename.className = 'img-multi__filename';
    filename.textContent = entry.file.name;

    item.appendChild(thumb);
    item.appendChild(removeBtn);
    item.appendChild(filename);
    gridEl.appendChild(item);
  });
}

function removeFile(id) {
  // idでファイルを指定するため、同名ファイルが複数あっても正しい1件だけ削除できる
  var target = selectedFiles.filter(function (entry) { return entry.id === id; })[0];
  if (!target) { return; }

  URL.revokeObjectURL(target.url); // メモリリークを防ぐ
  selectedFiles = selectedFiles.filter(function (entry) { return entry.id !== id; });

  hideError();
  renderGrid();
  updateCount();
}

function updateCount() {
  countEl.textContent = selectedFiles.length + ' / ' + MAX_FILES + '枚選択中';
}

function showError(message) {
  errorEl.textContent = message;
  errorEl.hidden = false;
}

function hideError() {
  errorEl.textContent = '';
  errorEl.hidden = true;
}

function resetDemo() {
  selectedFiles.forEach(function (entry) {
    URL.revokeObjectURL(entry.url);
  });
  selectedFiles = [];
  imageInput.value = '';
  hideError();
  renderGrid();
  updateCount();
}

AI用プロンプト

このプロンプトをChatGPTやClaudeに渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# 複数画像アップロード(プレビュー・削除・枚数制限)作成依頼

## 概要
複数の画像ファイルをクリック選択またはドラッグ&ドロップでまとめて受け取り、
サーバー送信前にサムネイル一覧としてプレビュー表示するUIを実装してください。
一覧の各画像は個別に削除でき、選択できる枚数には上限を設けてください。

## 要件
- クリックでファイル選択ダイアログ(複数選択可)を開けること
- エリアへのドラッグ&ドロップでも複数画像を追加できること
- 選択した画像をサムネイルのグリッドとしてプレビュー表示すること
- 各サムネイルに削除ボタンを表示し、押すとその画像だけを一覧から削除できること
- 選択中の枚数を「n / 5枚選択中」の形式で常に表示すること
- 選択できる枚数の上限は5枚とし、上限を超える枚数を一度に選択・ドロップした場合は、新しいファイルを1枚も追加せずエラーメッセージを表示すること(既存の選択済み画像は維持すること)
- 画像以外のファイルが含まれていた場合は、そのファイルだけを除外し、除外した旨をメッセージで表示すること
- デモエリア内に「このデモはサーバーへの送信を行いません」という注記を表示すること
- リセットボタンで選択済みの全画像とメッセージを初期状態に戻せること

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要

## 動作詳細
選択・ドロップされたファイルは配列で管理し、各ファイルにはファイル名ではなく連番などの一意なIDを割り当てて、削除時に正しい1件だけを特定できるようにしてください(同名ファイルの誤削除を防ぐため)。
プレビュー画像の表示には URL.createObjectURL(file) を使用し、削除時には URL.revokeObjectURL() でメモリを解放してください。
枚数上限のチェックは「既存の選択数+新規の画像ファイル数」が上限を超えるかどうかで判定し、超える場合は操作そのものを取り消してください。
ドラッグ&ドロップは dragover / dragleave / drop イベントで実装し、dragover では e.preventDefault() でブラウザデフォルトの動作を抑制してください。
サムネイルやファイル名の表示は innerHTML に文字列を直接結合せず、createElement と textContent で組み立ててください。

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。