アップロード進捗

複数ファイルの進捗を行ごとに表示し、擬似的な失敗が起きた行は自動と手動のどちらでも再試行できるアップロード進捗パネルです。

#css#js#progress

ライブデモ

コード

HTML
<!-- アップロード進捗: 複数ファイルの行ごとに進捗バーを表示し、失敗した行は再試行できる -->
<div class="stage">
  <div class="uploader">
    <div class="uploader__head">
      <span class="uploader__title">3個のファイルをアップロード中</span>
      <span class="uploader__count" id="supCount" aria-live="polite">0 / 3 完了</span>
    </div>
    <ul class="uploader__list" id="supList">
      <li class="file-row" data-file="0">
        <span class="file-row__icon" aria-hidden="true">🗂️</span>
        <div class="file-row__body">
          <div class="file-row__meta">
            <span class="file-row__name">design-assets.zip</span>
            <span class="file-row__size">18.2 MB</span>
          </div>
          <div class="file-row__track" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="design-assets.zip の進捗">
            <div class="file-row__fill"></div>
          </div>
          <span class="file-row__status">待機中…</span>
        </div>
        <button type="button" class="file-row__retry" hidden>再試行</button>
      </li>
      <li class="file-row" data-file="1">
        <span class="file-row__icon" aria-hidden="true">📄</span>
        <div class="file-row__body">
          <div class="file-row__meta">
            <span class="file-row__name">presentation.pdf</span>
            <span class="file-row__size">6.4 MB</span>
          </div>
          <div class="file-row__track" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="presentation.pdf の進捗">
            <div class="file-row__fill"></div>
          </div>
          <span class="file-row__status">待機中…</span>
        </div>
        <button type="button" class="file-row__retry" hidden>再試行</button>
      </li>
      <li class="file-row" data-file="2">
        <span class="file-row__icon" aria-hidden="true">🎬</span>
        <div class="file-row__body">
          <div class="file-row__meta">
            <span class="file-row__name">video-final.mp4</span>
            <span class="file-row__size">142 MB</span>
          </div>
          <div class="file-row__track" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="video-final.mp4 の進捗">
            <div class="file-row__fill"></div>
          </div>
          <span class="file-row__status">待機中…</span>
        </div>
        <button type="button" class="file-row__retry" hidden>再試行</button>
      </li>
    </ul>
  </div>
</div>
CSS
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  background:
    radial-gradient(circle at 12% 10%, #1c2636 0%, transparent 48%),
    radial-gradient(circle at 90% 88%, #23202f 0%, transparent 50%),
    #0c0e15;
  color: #eef1f6;
}

.stage {
  width: 100%;
  display: grid;
  place-items: center;
}

.uploader {
  width: min(400px, 100%);
  padding: 22px 20px 18px;
  border-radius: 18px;
  background: linear-gradient(165deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.015));
  border: 1px solid rgba(255, 255, 255, 0.08);
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.42);
}

.uploader__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 14px;
}

.uploader__title {
  font-size: 13px;
  font-weight: 700;
  color: rgba(238, 241, 246, 0.9);
}

.uploader__count {
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  color: rgba(238, 241, 246, 0.5);
}

.uploader__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.file-row {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px 10px;
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid rgba(255, 255, 255, 0.05);
  transition: background-color 0.3s ease, border-color 0.3s ease;
}

.file-row__icon {
  font-size: 18px;
  line-height: 1;
  margin-top: 2px;
  filter: grayscale(0.15);
}

.file-row__body {
  flex: 1 1 auto;
  min-width: 0;
}

.file-row__meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 6px;
}

.file-row__name {
  font-size: 12.5px;
  font-weight: 600;
  color: rgba(238, 241, 246, 0.9);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.file-row__size {
  flex: 0 0 auto;
  font-size: 11px;
  color: rgba(238, 241, 246, 0.4);
}

.file-row__track {
  position: relative;
  height: 7px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.08);
  overflow: hidden;
}

.file-row__fill {
  height: 100%;
  width: 0%;
  border-radius: 999px;
  background: linear-gradient(90deg, #38bdf8, #818cf8);
  transition: width 0.1s linear, background 0.35s ease;
}

.file-row__status {
  display: inline-block;
  margin-top: 6px;
  font-size: 11px;
  color: rgba(238, 241, 246, 0.42);
}

.file-row__retry {
  flex: 0 0 auto;
  align-self: center;
  padding: 6px 12px;
  font-size: 11.5px;
  font-weight: 600;
  border-radius: 999px;
  border: 1px solid rgba(248, 113, 113, 0.4);
  background: rgba(248, 113, 113, 0.12);
  color: #fca5a5;
  cursor: pointer;
  transition: transform 0.15s ease, background-color 0.2s ease;
}

.file-row__retry:active {
  transform: scale(0.95);
}

/* 進行中 */
.file-row.is-progress .file-row__status {
  color: #93c5fd;
}

/* 成功 */
.file-row.is-success {
  border-color: rgba(52, 211, 153, 0.25);
  background: rgba(52, 211, 153, 0.06);
}

.file-row.is-success .file-row__fill {
  background: linear-gradient(90deg, #34d399, #22c07a);
}

.file-row.is-success .file-row__status {
  color: #6ee7b7;
  font-weight: 600;
}

.file-row.is-success .file-row__status::before {
  content: "✓ ";
}

/* 失敗 */
.file-row.is-error {
  border-color: rgba(248, 113, 113, 0.3);
  background: rgba(248, 113, 113, 0.06);
}

.file-row.is-error .file-row__fill {
  background: linear-gradient(90deg, #f87171, #fb923c);
}

.file-row.is-error .file-row__status {
  color: #fca5a5;
  font-weight: 600;
}

@media (max-width: 380px) {
  .uploader { padding: 18px 14px 14px; }
  .file-row { flex-wrap: wrap; }
}
JavaScript
// アップロード進捗: 複数ファイルの進捗を並行アニメーションし、失敗行は自動/手動で再試行してループする
(() => {
  const list = document.getElementById('supList');
  const countEl = document.getElementById('supCount');
  if (!list) return; // null安全

  const rows = Array.from(list.querySelectorAll('.file-row'));
  if (!rows.length) return;

  const TOTAL = rows.length;
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // ファイルごとの疑似アップロード時間と、1回目だけ失敗させる位置(%)
  const CONFIG = [
    { duration: 2600, failAt: null },
    { duration: 3400, failAt: null },
    { duration: 2200, failAt: 62 },
  ];

  function parts(row) {
    return {
      fill: row.querySelector('.file-row__fill'),
      track: row.querySelector('.file-row__track'),
      status: row.querySelector('.file-row__status'),
      retry: row.querySelector('.file-row__retry'),
    };
  }

  function updateCount() {
    if (!countEl) return;
    const done = rows.filter((r) => r.classList.contains('is-success')).length;
    countEl.textContent = `${done} / ${TOTAL} 完了`;
  }

  function setProgress(row, p) {
    const { fill, track } = parts(row);
    if (fill) fill.style.width = p + '%';
    if (track) track.setAttribute('aria-valuenow', String(Math.round(p)));
  }

  function startRow(row, idx, attempt) {
    const cfg = CONFIG[idx];
    const { status, retry } = parts(row);

    row.classList.remove('is-success', 'is-error');
    row.classList.add('is-progress');
    if (retry) retry.hidden = true;
    if (status) status.textContent = 'アップロード中…';

    // 1回目の試行のみ失敗させ、再試行(2回目)は必ず成功させる
    const failAt = attempt === 1 ? cfg.failAt : null;
    let startTime = null;

    function frame(now) {
      if (startTime === null) startTime = now;
      const elapsed = now - startTime;
      const p = Math.min(100, (elapsed / cfg.duration) * 100);

      if (failAt !== null && p >= failAt) {
        setProgress(row, failAt);
        failRow(row, idx);
        return;
      }

      setProgress(row, p);

      if (p >= 100) {
        succeedRow(row);
      } else {
        requestAnimationFrame(frame);
      }
    }

    requestAnimationFrame(frame);
  }

  function succeedRow(row) {
    row.classList.remove('is-progress');
    row.classList.add('is-success');
    const { status } = parts(row);
    if (status) status.textContent = '完了しました';
    updateCount();
    maybeFinishAll();
  }

  function failRow(row, idx) {
    row.classList.remove('is-progress');
    row.classList.add('is-error');
    const { status, retry } = parts(row);
    if (status) status.textContent = '通信エラーが発生しました';
    if (retry) {
      retry.hidden = false;
      retry.onclick = () => startRow(row, idx, 2);
    }

    // 操作しなくても動いて見えるよう、数秒後に自動でも再試行する
    setTimeout(() => {
      if (row.classList.contains('is-error')) startRow(row, idx, 2);
    }, 2400);
  }

  function maybeFinishAll() {
    const allDone = rows.every((r) => r.classList.contains('is-success'));
    if (allDone) setTimeout(resetAll, 2600);
  }

  function resetAll() {
    rows.forEach((row) => {
      row.classList.remove('is-success', 'is-error', 'is-progress');
      setProgress(row, 0);
      const { status, retry } = parts(row);
      if (status) status.textContent = '待機中…';
      if (retry) retry.hidden = true;
    });
    updateCount();
    runAll();
  }

  function runAll() {
    rows.forEach((row, idx) => {
      setTimeout(() => startRow(row, idx, 1), idx * 450);
    });
  }

  if (reduce) {
    // モーション抑制時は最終的な完了状態のみを静的に表示
    rows.forEach((row) => {
      row.classList.add('is-success');
      setProgress(row, 100);
      const { status } = parts(row);
      if (status) status.textContent = '完了しました';
    });
    updateCount();
    return;
  }

  runAll();
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「アップロード進捗」の効果を追加してください。

# 追加してほしい効果
アップロード進捗(カレンダー & ステップ)
複数ファイルの進捗を行ごとに表示し、擬似的な失敗が起きた行は自動と手動のどちらでも再試行できるアップロード進捗パネルです。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- アップロード進捗: 複数ファイルの行ごとに進捗バーを表示し、失敗した行は再試行できる -->
<div class="stage">
  <div class="uploader">
    <div class="uploader__head">
      <span class="uploader__title">3個のファイルをアップロード中</span>
      <span class="uploader__count" id="supCount" aria-live="polite">0 / 3 完了</span>
    </div>
    <ul class="uploader__list" id="supList">
      <li class="file-row" data-file="0">
        <span class="file-row__icon" aria-hidden="true">🗂️</span>
        <div class="file-row__body">
          <div class="file-row__meta">
            <span class="file-row__name">design-assets.zip</span>
            <span class="file-row__size">18.2 MB</span>
          </div>
          <div class="file-row__track" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="design-assets.zip の進捗">
            <div class="file-row__fill"></div>
          </div>
          <span class="file-row__status">待機中…</span>
        </div>
        <button type="button" class="file-row__retry" hidden>再試行</button>
      </li>
      <li class="file-row" data-file="1">
        <span class="file-row__icon" aria-hidden="true">📄</span>
        <div class="file-row__body">
          <div class="file-row__meta">
            <span class="file-row__name">presentation.pdf</span>
            <span class="file-row__size">6.4 MB</span>
          </div>
          <div class="file-row__track" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="presentation.pdf の進捗">
            <div class="file-row__fill"></div>
          </div>
          <span class="file-row__status">待機中…</span>
        </div>
        <button type="button" class="file-row__retry" hidden>再試行</button>
      </li>
      <li class="file-row" data-file="2">
        <span class="file-row__icon" aria-hidden="true">🎬</span>
        <div class="file-row__body">
          <div class="file-row__meta">
            <span class="file-row__name">video-final.mp4</span>
            <span class="file-row__size">142 MB</span>
          </div>
          <div class="file-row__track" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-label="video-final.mp4 の進捗">
            <div class="file-row__fill"></div>
          </div>
          <span class="file-row__status">待機中…</span>
        </div>
        <button type="button" class="file-row__retry" hidden>再試行</button>
      </li>
    </ul>
  </div>
</div>

【CSS】
* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  padding: 24px;
  font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
  background:
    radial-gradient(circle at 12% 10%, #1c2636 0%, transparent 48%),
    radial-gradient(circle at 90% 88%, #23202f 0%, transparent 50%),
    #0c0e15;
  color: #eef1f6;
}

.stage {
  width: 100%;
  display: grid;
  place-items: center;
}

.uploader {
  width: min(400px, 100%);
  padding: 22px 20px 18px;
  border-radius: 18px;
  background: linear-gradient(165deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.015));
  border: 1px solid rgba(255, 255, 255, 0.08);
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.42);
}

.uploader__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 14px;
}

.uploader__title {
  font-size: 13px;
  font-weight: 700;
  color: rgba(238, 241, 246, 0.9);
}

.uploader__count {
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  color: rgba(238, 241, 246, 0.5);
}

.uploader__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.file-row {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px 10px;
  border-radius: 12px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid rgba(255, 255, 255, 0.05);
  transition: background-color 0.3s ease, border-color 0.3s ease;
}

.file-row__icon {
  font-size: 18px;
  line-height: 1;
  margin-top: 2px;
  filter: grayscale(0.15);
}

.file-row__body {
  flex: 1 1 auto;
  min-width: 0;
}

.file-row__meta {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 6px;
}

.file-row__name {
  font-size: 12.5px;
  font-weight: 600;
  color: rgba(238, 241, 246, 0.9);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.file-row__size {
  flex: 0 0 auto;
  font-size: 11px;
  color: rgba(238, 241, 246, 0.4);
}

.file-row__track {
  position: relative;
  height: 7px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.08);
  overflow: hidden;
}

.file-row__fill {
  height: 100%;
  width: 0%;
  border-radius: 999px;
  background: linear-gradient(90deg, #38bdf8, #818cf8);
  transition: width 0.1s linear, background 0.35s ease;
}

.file-row__status {
  display: inline-block;
  margin-top: 6px;
  font-size: 11px;
  color: rgba(238, 241, 246, 0.42);
}

.file-row__retry {
  flex: 0 0 auto;
  align-self: center;
  padding: 6px 12px;
  font-size: 11.5px;
  font-weight: 600;
  border-radius: 999px;
  border: 1px solid rgba(248, 113, 113, 0.4);
  background: rgba(248, 113, 113, 0.12);
  color: #fca5a5;
  cursor: pointer;
  transition: transform 0.15s ease, background-color 0.2s ease;
}

.file-row__retry:active {
  transform: scale(0.95);
}

/* 進行中 */
.file-row.is-progress .file-row__status {
  color: #93c5fd;
}

/* 成功 */
.file-row.is-success {
  border-color: rgba(52, 211, 153, 0.25);
  background: rgba(52, 211, 153, 0.06);
}

.file-row.is-success .file-row__fill {
  background: linear-gradient(90deg, #34d399, #22c07a);
}

.file-row.is-success .file-row__status {
  color: #6ee7b7;
  font-weight: 600;
}

.file-row.is-success .file-row__status::before {
  content: "✓ ";
}

/* 失敗 */
.file-row.is-error {
  border-color: rgba(248, 113, 113, 0.3);
  background: rgba(248, 113, 113, 0.06);
}

.file-row.is-error .file-row__fill {
  background: linear-gradient(90deg, #f87171, #fb923c);
}

.file-row.is-error .file-row__status {
  color: #fca5a5;
  font-weight: 600;
}

@media (max-width: 380px) {
  .uploader { padding: 18px 14px 14px; }
  .file-row { flex-wrap: wrap; }
}

【JavaScript】
// アップロード進捗: 複数ファイルの進捗を並行アニメーションし、失敗行は自動/手動で再試行してループする
(() => {
  const list = document.getElementById('supList');
  const countEl = document.getElementById('supCount');
  if (!list) return; // null安全

  const rows = Array.from(list.querySelectorAll('.file-row'));
  if (!rows.length) return;

  const TOTAL = rows.length;
  const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  // ファイルごとの疑似アップロード時間と、1回目だけ失敗させる位置(%)
  const CONFIG = [
    { duration: 2600, failAt: null },
    { duration: 3400, failAt: null },
    { duration: 2200, failAt: 62 },
  ];

  function parts(row) {
    return {
      fill: row.querySelector('.file-row__fill'),
      track: row.querySelector('.file-row__track'),
      status: row.querySelector('.file-row__status'),
      retry: row.querySelector('.file-row__retry'),
    };
  }

  function updateCount() {
    if (!countEl) return;
    const done = rows.filter((r) => r.classList.contains('is-success')).length;
    countEl.textContent = `${done} / ${TOTAL} 完了`;
  }

  function setProgress(row, p) {
    const { fill, track } = parts(row);
    if (fill) fill.style.width = p + '%';
    if (track) track.setAttribute('aria-valuenow', String(Math.round(p)));
  }

  function startRow(row, idx, attempt) {
    const cfg = CONFIG[idx];
    const { status, retry } = parts(row);

    row.classList.remove('is-success', 'is-error');
    row.classList.add('is-progress');
    if (retry) retry.hidden = true;
    if (status) status.textContent = 'アップロード中…';

    // 1回目の試行のみ失敗させ、再試行(2回目)は必ず成功させる
    const failAt = attempt === 1 ? cfg.failAt : null;
    let startTime = null;

    function frame(now) {
      if (startTime === null) startTime = now;
      const elapsed = now - startTime;
      const p = Math.min(100, (elapsed / cfg.duration) * 100);

      if (failAt !== null && p >= failAt) {
        setProgress(row, failAt);
        failRow(row, idx);
        return;
      }

      setProgress(row, p);

      if (p >= 100) {
        succeedRow(row);
      } else {
        requestAnimationFrame(frame);
      }
    }

    requestAnimationFrame(frame);
  }

  function succeedRow(row) {
    row.classList.remove('is-progress');
    row.classList.add('is-success');
    const { status } = parts(row);
    if (status) status.textContent = '完了しました';
    updateCount();
    maybeFinishAll();
  }

  function failRow(row, idx) {
    row.classList.remove('is-progress');
    row.classList.add('is-error');
    const { status, retry } = parts(row);
    if (status) status.textContent = '通信エラーが発生しました';
    if (retry) {
      retry.hidden = false;
      retry.onclick = () => startRow(row, idx, 2);
    }

    // 操作しなくても動いて見えるよう、数秒後に自動でも再試行する
    setTimeout(() => {
      if (row.classList.contains('is-error')) startRow(row, idx, 2);
    }, 2400);
  }

  function maybeFinishAll() {
    const allDone = rows.every((r) => r.classList.contains('is-success'));
    if (allDone) setTimeout(resetAll, 2600);
  }

  function resetAll() {
    rows.forEach((row) => {
      row.classList.remove('is-success', 'is-error', 'is-progress');
      setProgress(row, 0);
      const { status, retry } = parts(row);
      if (status) status.textContent = '待機中…';
      if (retry) retry.hidden = true;
    });
    updateCount();
    runAll();
  }

  function runAll() {
    rows.forEach((row, idx) => {
      setTimeout(() => startRow(row, idx, 1), idx * 450);
    });
  }

  if (reduce) {
    // モーション抑制時は最終的な完了状態のみを静的に表示
    rows.forEach((row) => {
      row.classList.add('is-success');
      setProgress(row, 100);
      const { status } = parts(row);
      if (status) status.textContent = '完了しました';
    });
    updateCount();
    return;
  }

  runAll();
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。