フォームの送信ボタンを押した後、画面が何も変わらないままだとユーザーは「送信されたのか?」と不安になります。ローディングアニメーションを表示することで「処理中です」と伝えられ、UX が大きく向上します。
この記事では、外部の gif 画像に頼らない純粋な CSS スピナーの作り方から、fetch と async/await を組み合わせた実践的なローディング制御、アクセシビリティ対応まで、コピペして即使えるコードとともに解説します。
実装の全体像
フォーム送信時のローディング実装は、大きく3つのステップで構成されます。
- 送信ボタンを無効化し、テキストを「送信中…」に変える(二重送信防止)
- スピナー/オーバーレイを表示する
- 処理完了後(成功・失敗どちらでも)
finallyでボタンを元に戻し、スピナーを非表示にする
try-catch-finally の finally ブロックで必ず後処理を行いましょう。CSS だけで作るスピナー3種(gif 不要)
外部ファイルに依存しない CSS スピナーは、軽量でどこでも使えます。代表的な3パターンを紹介します。
パターン1:リングスピナー(最定番)
.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); }
}
<div class="spinner-ring" aria-label="読み込み中" role="status"></div>
パターン2:ドットスピナー(3点点滅)
.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; }
}
<div class="spinner-dots" aria-label="読み込み中" role="status"> <span></span><span></span><span></span> </div>
パターン3:バースピナー(音波風)
.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; }
}
<div class="spinner-bars" aria-label="読み込み中" role="status"> <span></span><span></span><span></span><span></span> </div>
基本実装:ボタン内にスピナーを表示する
最も使いやすい実装パターンです。送信ボタン自体にスピナーを組み込み、テキストと状態を切り替えます。フルスクリーンオーバーレイより軽量でシンプルです。
<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>
#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 クラスがないとき "送信する" を表示 */
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の使い方で詳しく解説しています。
フルスクリーンオーバーレイのローディング
複数の処理が連続する場合や、ページ全体の操作をブロックしたい場合はフルスクリーンオーバーレイが適しています。
<!-- 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>
.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; }
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 で非表示にするとフェードアニメーションが動作しません。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】送信ボタンの二重クリックを防止する方法も参照してください。
タイムアウト処理を組み込む
サーバーが応答しない場合、ローディングが永遠に表示され続けます。AbortController の abort() を呼ぶと fetch がキャンセルされ、catch ブロックで err.name === "AbortError" として検知できます。これを setTimeout と組み合わせることで、指定時間を超えたら自動中断するタイムアウトを実現できます。なお AbortError は「タイムアウトによる中断」だけでなく「手動でキャンセルした場合」にも発生するため、判定条件を必要に応じてフラグで区別することもできます。
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();
}
});
アップロード進捗をプログレスバーで表示する
ファイルアップロードなど進捗を表示できる場合は、XMLHttpRequest の progress イベントを使ってプログレスバーを更新できます。fetch は標準では進捗取得に対応していないため、この用途では XHR が有効です。
<progress id="upload-progress" value="0" max="100" style="display:none;width:100%;"></progress> <p id="progress-text" style="display:none;">0%</p>
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 ? '送信中です...' : '';
}
}
<!-- スクリーンリーダーへの状態通知用(視覚的には非表示) --> <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になったとき初めて非表示)を使います。
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 になったときだけ非表示になる
}
});
よくある質問
try-catch-finally の finally ブロックに必ず後処理を書いてください。e.preventDefault() なし)はページ遷移が発生するため、ローディングを消す処理は不要です。ただし、ブラウザの戻るボタンで戻ってきたとき用に pageshow イベントで非表示にする保険を入れると安全です:window.addEventListener("pageshow", () => hideLoading())。prefers-reduced-motion メディアクエリでアニメーションを止める配慮もしやすくなります。pointer-events: none を body に当てる方法もありますが、スクロールや Tab キーのフォーカスは止められません。オーバーレイは視覚的なブロックのみで、アクセシビリティ上は inert 属性を背景コンテンツに付けることが推奨されています(モダンブラウザ対応)。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でフォーム送信を簡単にする方法もあわせてご覧ください。