【JavaScript】フォーム送信のローディング実装完全ガイド|CSSスピナー・fetch連携・アクセシビリティ対応まで解説

フォームの送信ボタンを押した後、画面が何も変わらないままだとユーザーは「送信されたのか?」と不安になります。ローディングアニメーションを表示することで「処理中です」と伝えられ、UX が大きく向上します。

この記事では、外部の gif 画像に頼らない純粋な CSS スピナーの作り方から、fetchasync/await を組み合わせた実践的なローディング制御、アクセシビリティ対応まで、コピペして即使えるコードとともに解説します。

スポンサーリンク

実装の全体像

フォーム送信時のローディング実装は、大きく3つのステップで構成されます。

  1. 送信ボタンを無効化し、テキストを「送信中…」に変える(二重送信防止)
  2. スピナー/オーバーレイを表示する
  3. 処理完了後(成功・失敗どちらでも)finally でボタンを元に戻し、スピナーを非表示にする
finally が重要:エラーが発生してもローディングが消えないと、ユーザーは画面が固まったと判断します。try-catch-finally の finally ブロックで必ず後処理を行いましょう。

CSS だけで作るスピナー3種(gif 不要)

外部ファイルに依存しない CSS スピナーは、軽量でどこでも使えます。代表的な3パターンを紹介します。

パターン1:リングスピナー(最定番)

CSS(リングスピナー)
.spinner-ring {
  width: 40px;
  height: 40px;
  border: 4px solid #e2e8f0;
  border-top-color: #0284c7;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}
HTML(リングスピナー)
<div class="spinner-ring" aria-label="読み込み中" role="status"></div>

パターン2:ドットスピナー(3点点滅)

CSS(ドットスピナー)
.spinner-dots {
  display: flex;
  gap: 6px;
  align-items: center;
}

.spinner-dots span {
  width: 10px;
  height: 10px;
  background: #0284c7;
  border-radius: 50%;
  animation: dot-bounce 1.2s ease-in-out infinite;
}

.spinner-dots span:nth-child(2) { animation-delay: 0.2s; }
.spinner-dots span:nth-child(3) { animation-delay: 0.4s; }

@keyframes dot-bounce {
  0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
  40%            { transform: scale(1);   opacity: 1; }
}
HTML(ドットスピナー)
<div class="spinner-dots" aria-label="読み込み中" role="status">
  <span></span><span></span><span></span>
</div>

パターン3:バースピナー(音波風)

CSS(バースピナー)
.spinner-bars {
  display: flex;
  gap: 4px;
  align-items: flex-end;
  height: 32px;
}

.spinner-bars span {
  width: 6px;
  background: #0284c7;
  border-radius: 3px;
  animation: bar-scale 1s ease-in-out infinite;
}

.spinner-bars span:nth-child(1) { animation-delay: 0s;    height: 60%; }
.spinner-bars span:nth-child(2) { animation-delay: 0.15s; height: 100%; }
.spinner-bars span:nth-child(3) { animation-delay: 0.3s;  height: 60%; }
.spinner-bars span:nth-child(4) { animation-delay: 0.45s; height: 80%; }

@keyframes bar-scale {
  0%, 100% { transform: scaleY(0.5); opacity: 0.6; }
  50%       { transform: scaleY(1);   opacity: 1; }
}
HTML(バースピナー)
<div class="spinner-bars" aria-label="読み込み中" role="status">
  <span></span><span></span><span></span><span></span>
</div>

基本実装:ボタン内にスピナーを表示する

最も使いやすい実装パターンです。送信ボタン自体にスピナーを組み込み、テキストと状態を切り替えます。フルスクリーンオーバーレイより軽量でシンプルです。

HTML
<form id="contactForm">
  <input type="text" name="name" placeholder="お名前" required>
  <input type="email" name="email" placeholder="メールアドレス" required>
  <button type="submit" id="submitBtn">
    <span class="btn-text">送信する</span>
    <span class="spinner-ring btn-spinner" aria-hidden="true"></span>
  </button>
</form>
CSS(ボタン内スピナー)
#submitBtn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 24px;
  background: #0284c7;
  color: #fff;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 1rem;
  transition: opacity 0.2s;
}

#submitBtn:disabled { opacity: 0.6; cursor: not-allowed; }

.btn-spinner {
  width: 16px;
  height: 16px;
  border-width: 2px;
  border-color: rgba(255,255,255,0.4);
  border-top-color: #fff;
  display: none; /* 初期非表示 */
}

#submitBtn.loading .btn-spinner { display: inline-block; }
#submitBtn.loading .btn-text::after { content: "送信中…"; }
#submitBtn.loading .btn-text { display: none; }
/* .btn-text は loading クラスがないとき "送信する" を表示 */
JavaScript(fetch + async/await + finally)
const form = document.getElementById('contactForm');
const btn  = document.getElementById('submitBtn');

form.addEventListener('submit', async (e) => {
  e.preventDefault();

  // ① ローディング開始
  btn.classList.add('loading');
  btn.disabled = true;
  btn.setAttribute('aria-busy', 'true');

  try {
    const res = await fetch('/api/contact', {
      method: 'POST',
      body: new FormData(form),
    });

    if (!res.ok) throw new Error(`サーバーエラー: ${res.status}`);

    const data = await res.json();
    alert('送信が完了しました!');
    form.reset();

  } catch (err) {
    console.error(err);
    alert('送信に失敗しました。もう一度お試しください。');

  } finally {
    // ② 成功・失敗どちらでも必ず元に戻す
    btn.classList.remove('loading');
    btn.disabled = false;
    btn.removeAttribute('aria-busy');
  }
});

async/await の基本は【JavaScript】Promiseとasync/awaitの使い方で詳しく解説しています。

フルスクリーンオーバーレイのローディング

複数の処理が連続する場合や、ページ全体の操作をブロックしたい場合はフルスクリーンオーバーレイが適しています。

HTML(オーバーレイ)
<!-- body の直下に配置 -->
<div id="overlay" class="overlay" aria-hidden="true" role="status" aria-label="読み込み中">
  <div class="overlay-inner">
    <div class="spinner-ring spinner-lg"></div>
    <p class="overlay-text">送信中...</p>
  </div>
</div>
CSS(オーバーレイ)
.overlay {
  position: fixed;
  inset: 0; /* top/right/bottom/left: 0 の短縮形 */
  background: rgba(15, 23, 42, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 9999;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.2s, visibility 0.2s;
}

.overlay.active {
  opacity: 1;
  visibility: visible;
}

.overlay-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  color: #fff;
}

.spinner-lg { width: 56px; height: 56px; border-width: 5px; }
.overlay-text { font-size: 1rem; margin: 0; }
JavaScript(オーバーレイ表示・非表示)
const overlay = document.getElementById('overlay');

function showLoading(message = '処理中...') {
  overlay.querySelector('.overlay-text').textContent = message;
  overlay.classList.add('active');
  overlay.setAttribute('aria-hidden', 'false');
  document.body.style.overflow = 'hidden'; // 背景スクロール禁止
}

function hideLoading() {
  overlay.classList.remove('active');
  overlay.setAttribute('aria-hidden', 'true');
  document.body.style.overflow = '';
}

// 使い方
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  showLoading('送信中...');
  try {
    await fetch('/api/submit', { method: 'POST', body: new FormData(form) });
    alert('完了しました');
  } catch {
    alert('エラーが発生しました');
  } finally {
    hideLoading();
  }
});
display: none vs visibility/opacity:display: none で非表示にするとフェードアニメーションが動作しません。opacity + visibility の組み合わせを使うことで、フェードイン/アウトが機能します。

ボタンのテキストとアイコンだけ変えるシンプルパターン

スピナーを使わず、ボタンのテキストだけを変えるシンプルなパターンです。軽量な実装が必要なときや、ボタン以外の UI を変えたくないときに適しています。

ボタンテキストを送信中に変える
const btn = document.getElementById('submitBtn');
const originalText = btn.textContent;

form.addEventListener('submit', async (e) => {
  e.preventDefault();

  btn.textContent = '送信中...';
  btn.disabled = true;

  try {
    await fetch('/api/submit', { method: 'POST', body: new FormData(form) });
    btn.textContent = '✓ 送信完了';
    btn.style.background = '#10b981';
    setTimeout(() => {
      btn.textContent = originalText;
      btn.style.background = '';
      btn.disabled = false;
    }, 2000);
  } catch {
    btn.textContent = '再送信';
    btn.disabled = false;
  }
});

ボタンの二重クリック防止と組み合わせる実装は【JavaScript】送信ボタンの二重クリックを防止する方法も参照してください。

タイムアウト処理を組み込む

サーバーが応答しない場合、ローディングが永遠に表示され続けます。AbortControllerabort() を呼ぶと fetch がキャンセルされ、catch ブロックで err.name === "AbortError" として検知できます。これを setTimeout と組み合わせることで、指定時間を超えたら自動中断するタイムアウトを実現できます。なお AbortError は「タイムアウトによる中断」だけでなく「手動でキャンセルした場合」にも発生するため、判定条件を必要に応じてフラグで区別することもできます。

タイムアウト付き fetch
async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) {
  const controller = new AbortController();
  const timerId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const res = await fetch(url, { ...options, signal: controller.signal });
    clearTimeout(timerId);
    return res;
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new Error('タイムアウト:サーバーからの応答がありませんでした');
    }
    throw err;
  }
}

// 使い方(10秒でタイムアウト)
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  showLoading();
  try {
    const res = await fetchWithTimeout('/api/submit', {
      method: 'POST',
      body: new FormData(form),
    }, 10000);
    // ...
  } catch (err) {
    alert(err.message);
  } finally {
    hideLoading();
  }
});

アップロード進捗をプログレスバーで表示する

ファイルアップロードなど進捗を表示できる場合は、XMLHttpRequestprogress イベントを使ってプログレスバーを更新できます。fetch は標準では進捗取得に対応していないため、この用途では XHR が有効です。

HTML(プログレスバー)
<progress id="upload-progress" value="0" max="100" style="display:none;width:100%;"></progress>
<p id="progress-text" style="display:none;">0%</p>
XHR でアップロード進捗を取得
function uploadWithProgress(form) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    const progressBar  = document.getElementById('upload-progress');
    const progressText = document.getElementById('progress-text');

    progressBar.style.display = 'block';
    progressText.style.display = 'block';

    xhr.upload.addEventListener('progress', (e) => {
      if (!e.lengthComputable) return;
      const pct = Math.round((e.loaded / e.total) * 100);
      progressBar.value = pct;
      progressText.textContent = `${pct}%`;
    });

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText));
      } else {
        reject(new Error(`サーバーエラー: ${xhr.status}`));
      }
    });

    xhr.addEventListener('error', () => reject(new Error('ネットワークエラー')));

    xhr.open('POST', '/api/upload');
    xhr.send(new FormData(form));
  });
}

アクセシビリティ対応(aria-busy / aria-label)

スクリーンリーダーのユーザーにもローディング状態を伝えるため、WAI-ARIA 属性を適切に設定しましょう。

属性 用途 値の例
aria-busy 処理中であることを通知 true / false
aria-label スピナー要素の説明 “読み込み中”
role=”status” 状態変化をアナウンス
aria-hidden 装飾的なスピナーを読み上げ除外 true
アクセシビリティを考慮した実装
function setLoadingState(form, btn, isLoading) {
  // フォーム全体に処理中フラグ
  form.setAttribute('aria-busy', String(isLoading));

  // ボタンの状態
  btn.disabled = isLoading;
  if (isLoading) {
    btn.setAttribute('aria-label', '送信中。しばらくお待ちください');
  } else {
    btn.removeAttribute('aria-label');
  }

  // ライブリージョンへの通知
  const liveRegion = document.getElementById('form-status');
  if (liveRegion) {
    liveRegion.textContent = isLoading ? '送信中です...' : '';
  }
}
HTML(ライブリージョン)
<!-- スクリーンリーダーへの状態通知用(視覚的には非表示) -->
<div
  id="form-status"
  role="status"
  aria-live="polite"
  aria-atomic="true"
  style="position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0);"
></div>

再利用可能な LoadingManager クラス

複数のフォームで同じ処理を書かないよう、ローディング制御をクラスにまとめると保守しやすくなります。

注意:並走する複数の非同期処理(例:入力補完 API とフォーム送信 API を同時に呼ぶ)がある場合、シンプルな start/end だと1つが終わった時点でローディングが消えてしまいます。その場合はカウンター方式(処理数をカウントし、0になったとき初めて非表示)を使います。

LoadingManager クラス(カウンター方式対応)
class LoadingManager {
  #count = 0; // 進行中の処理数

  constructor(formEl, btnEl, overlayEl = null) {
    this.form    = formEl;
    this.btn     = btnEl;
    this.overlay = overlayEl;
    this.originalBtnText = btnEl.innerHTML;
  }

  start(btnLabel = '送信中...') {
    this.#count++;
    this.btn.innerHTML = `<span class='spinner-ring btn-spinner'></span>${btnLabel}`;
    this.btn.disabled  = true;
    this.form.setAttribute('aria-busy', 'true');
    this.overlay?.classList.add('active');
    document.body.style.overflow = this.overlay ? 'hidden' : '';
  }

  end() {
    this.#count = Math.max(0, this.#count - 1);
    if (this.#count > 0) return; // まだ進行中の処理がある
    this.btn.innerHTML = this.originalBtnText;
    this.btn.disabled  = false;
    this.form.removeAttribute('aria-busy');
    this.overlay?.classList.remove('active');
    document.body.style.overflow = '';
  }
}

// 使い方
const loader = new LoadingManager(
  document.getElementById('contactForm'),
  document.getElementById('submitBtn'),
  document.getElementById('overlay') // オーバーレイなしなら省略可
);

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  loader.start();
  try {
    await fetch('/api/submit', { method: 'POST', body: new FormData(form) });
  } finally {
    loader.end(); // カウンターが 0 になったときだけ非表示になる
  }
});

よくある質問

Qfinally を使わないとどうなりますか?
Aエラーが発生したとき(サーバーエラー・ネットワーク切断など)にローディングが消えず、ユーザーはボタンを押せない状態のまま取り残されます。try-catch-finally の finally ブロックに必ず後処理を書いてください。
Qフォームを通常 submit(fetch なし)で送信する場合はローディングを消す必要がありますか?
A通常の form submit(e.preventDefault() なし)はページ遷移が発生するため、ローディングを消す処理は不要です。ただし、ブラウザの戻るボタンで戻ってきたとき用に pageshow イベントで非表示にする保険を入れると安全です:window.addEventListener("pageshow", () => hideLoading())
Qgif 画像のスピナーより CSS スピナーを使うべき理由は?
ACSS スピナーは追加ファイルが不要で HTTP リクエストが減ります。色・サイズを CSS 変数で一元管理でき、解像度に依存しないためレティナディスプレイでも鮮明に表示されます。また、prefers-reduced-motion メディアクエリでアニメーションを止める配慮もしやすくなります。
Qローディング中に他のボタンやリンクもクリックできないようにするには?
Aフルスクリーンオーバーレイを使う方法が最も簡単です。pointer-events: none を body に当てる方法もありますが、スクロールや Tab キーのフォーカスは止められません。オーバーレイは視覚的なブロックのみで、アクセシビリティ上は inert 属性を背景コンテンツに付けることが推奨されています(モダンブラウザ対応)。
QVue.js / React でローディングを管理するには?
Aフレームワークでは isLoading などの状態変数を用意し、テンプレートで v-if{ isLoading && } で制御するのが標準パターンです。この記事のロジック(try/finally での状態管理)はフレームワーク問わず共通です。

まとめ

フォーム送信時のローディング実装で押さえるべき核心は以下の3点です。

  • gif に頼らず CSS スピナーを使う(軽量・カスタマイズ容易・レティナ対応)
  • try-catch-finally の finally でローディングを確実に解除する(エラー時も含め)
  • aria-busy と role=”status” でスクリーンリーダーにも状態を伝える

さらに、ファイルアップロードの場合は XHR の progress イベントでプログレスバーを、長時間処理には AbortController でタイムアウトを組み合わせると、より堅牢な UX を提供できます。

FormData を使った Ajax 送信の基礎は【JavaScript】FormDataの使い方|Ajaxでフォーム送信を簡単にする方法もあわせてご覧ください。