GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

ボタン ローディング 9種 — スピナーから成功チェックまでの状態遷移

9種のボタン ローディングのうち、1つはスピナーが線を描くようにチェックマークへ形を変え、1つは文字の代わりに点が3つ順に大きくなっては小さくなり、1つはラベルが一定時間ごとに次の段階の文言へ上下にスライドして切り替わります。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

このツールキットは、1行のボタンに9種類のローディング表現をまとめています。スピナー切り替え・進捗塗りつぶし・チェックマーク変形・エラー揺れ・アイコンのみへの収縮・3点パルス・暗転ロック・複数段階の文言・reduced-motion時の静止ドットです。使いやすいローディングボタンの設計は見た目以上に繊細で、各パターンはライブラリや単一のCSSアニメーションではなく、名前の付いた小さな計算関数が動かしており、その中身はこの記事のコード欄でそのまま確認できます。

デモ

ラベルがスピナーアイコンに自然に切り替わるボタン
新しいタブで開く

01 · ラベルがスピナーアイコンに自然に切り替わるボタン — 計測 累積面積 0.503% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

lockWidthspinLooparia-busy

上のパターン01は、ラベルがフェードアウトする前にgetBoundingClientRectで幅を1回読み取るだけで、幅を固定するための特別なライブラリを必要としません。残る8種は下に続き、それぞれ自分のコード欄と対になっています。

9つの状態遷移の解剖 — どこで計算の一層が回るか

# パターン 累積面積 平均強度 動いたフレーム
01 ラベル→スピナーのクロスフェード 0.50% 98.6 23中20
02 プログレス塗りつぶし 5.05% 75.9 23中14
03 スピナー→チェックのモーフ 5.07% 90.8 23中15
04 揺れと復帰 0.73% 97.9 23中16
05 アイコンのみへの収縮 4.39% 168.1 23中13
06 3点パルス 0.21% 91.9 23中20
07 暗転とロック 3.32% 32.0 23中10
08 複数段階の文言 0.79% 161.3 23中10
09 reduced-motion時の静止ドット 0.59% 91.9 23中10

9つのうち最も広い面積を占めるのはパターン03のチェックマーク変形で5.07%、僅差(0.02ポイント差)でパターン02の進捗塗りつぶし(5.05%)が続きます。最も面積が小さいのはパターン06の3点パルスで0.21%です。平均強度が最も高いのはパターン05のアイコン収縮で168.1です。scaleXによる収縮とアイコン側の逆スケールが同時に動くため、変化が鋭く集中して見えます。逆に平均強度が最も低いのはパターン07の暗転ロックで32.0、フィルムの不透明度がごくわずかしか変化しないためです。測定した23フレーム対のうち最も多く動いたのはパターン01と06でそれぞれ20回、最も少なかったのは07・08・09でそれぞれ10回でした。

イージングされた進捗が一つの値を動かす — 02・03・05

パターン02・03・05はいずれも1つのrequestAnimationFrameループで進捗値pを毎フレーム計算し、それを複数のプロパティに同時に使います。drive(02)はpをeaseOutCubicで処理し、塗りつぶしのscaleXとパーセント表示のラベルに同じ値を書き込みます。morph(03)は同じpをリングのopacity・チェックのopacity・stroke-dashoffset・背景のティントという4つのプロパティに同時に使うため、アイコンが切り替わるのではなく連続的に形を変えているように見えます。driveScale(05)は同じステップでpをボタンのscaleXとアイコンの逆数1 / scaleの両方に変換するため、ボタンが狭くなる間もアイコン自体は潰れません。

周期的な波形が続く — 04・06・07

パターン04・06・07は目標値へイージングするのではなく、ループが動いている間ずっとサイン波を計算し続けます。shake(04)はMath.sinに減衰する指数関数を掛け合わせるため、振動の振幅が時間とともに徐々に小さくなり、急に止まることがありません。pulseLoop(06)は各ドットのMath.sinの位相をインデックス×PHASE_OFFSET_RADだけずらすため、3つの点は同じ式を共有しながら決して同期しません。pulseLoop(07)はこの3つの中で最も複雑で、振動数自体がBASE_HZからMAX_HZへ残り時間に応じて上がっていくためsin(2π・f・t)をそのまま使えず、位相をフレームごとに直接積分しています。

実測して切り替える離散的な状態 — 01・08・09

パターン01・08・09は連続的なアニメーションカーブを使わず、一度だけ何かを実測してから固定の状態を切り替えます。lockWidth(01)はラベルがフェードアウトする前にgetBoundingClientRectでボタンの現在の描画幅を読み取るため、スピナーが回っている間にボタンが狭く再フローすることがありません。measureOffsets(08)は行の高さを仮定せず、各段階文言の実際のoffsetTopを読み取るため、トラックは積み上がった文言の実際の高さの分だけ正確にスライドします。パターン09はレイアウトを一切測定しません。matchMedia('(prefers-reduced-motion: reduce)')を一度だけ読み取り、その後はrole="switch"のトグルがOSの設定にかかわらず訪問者が手動で両方の状態を切り替えられるようにしています。

パターン別コード

以下のコードはすべて各デモ自身のスクリプトからそのまま取り出したもので、短縮も書き換えもしていません。

01ラベルがスピナーアイコンに自然に切り替わるボタン

ボタンを押した瞬間に文字が薄くなって消え、その場所に小さな円形スピナーが薄く現れて回り続け、その間ボタンの幅は変わりません。フォーム送信ボタンがサーバーの応答を待つ間に向いています。

ラベルがスピナーアイコンに自然に切り替わるボタン
新しいタブで開く

01 · ラベルがスピナーアイコンに自然に切り替わるボタン — 計測 累積面積 0.503% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

lockWidthspinLooparia-busy
  function lockWidth() {
    var w = btn.getBoundingClientRect().width;
    btn.style.width = w + 'px';
  }
  function unlockWidth() { btn.style.width = ''; }

  function startLoad(myGen) {
    lockWidth();
    btn.disabled = true;
    btn.setAttribute('aria-busy', 'true');
    status.textContent = '保存中';
    fade(label, 1, 0, FADE_MS, myGen);
    fade(spinnerWrap, 0, 1, FADE_MS, myGen, function () { spinLoop(myGen); });
  }
  function finishLoad(myGen) {
    cancelAnimationFrame(rafId);
    fade(spinnerWrap, 1, 0, FADE_MS, myGen);
    fade(label, 0, 1, FADE_MS, myGen, function () {
      btn.disabled = false;
      btn.removeAttribute('aria-busy');
      status.textContent = '保存完了';
      unlockWidth();
    });
  }

02ボタンの背景が進捗分だけ満ちるミニプログレス表示

ボタンを押すと枠内の背景が左から右へ薄い色で満ちていき進捗を示し、端まで満ちると少し止まってから次の状態に移ります。ファイルアップロードや長い処理の進捗表示に使えます。

ボタンの背景が進捗分だけ満ちるミニプログレス表示
新しいタブで開く

02 · ボタンの背景が進捗分だけ満ちるミニプログレス表示 — 計測 累積面積 5.052% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

easeOutCubictransform-originscaleX
  // 計算層: 塗りつぶしバーは scaleX だけで伸びる(transform-origin: left — 開始辺から伸びるという
  // 例外的な origin だが、塗りつぶしバーなので許容する)。進捗 p は毎フレーム easeOutCubic で計算し、
  // 同じ値を style.transform とラベルのパーセント数値の両方に使う。
  function drive(myGen, onDone) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var raw = Math.min(1, (now - start) / FILL_MS);
      var p = easeOutCubic(raw);
      fill.style.transform = 'scaleX(' + p.toFixed(4) + ')';
      text.textContent = 'Uploading ' + Math.round(p * 100) + '%';
      if (raw < 1) requestAnimationFrame(step); else onDone();
    }
    requestAnimationFrame(step);
  }

03スピナーが成功のチェックアイコンに変形するボタン

回っていた円形スピナーは作業が終わる瞬間、線を描くようになめらかにチェックマークの形へ変わり、ボタンの背景も同時に色づきます。保存や決済の完了をボタンの中でそのまま知らせるのに向いています。

スピナーが成功のチェックアイコンに変形するボタン
新しいタブで開く

03 · スピナーが成功のチェックアイコンに変形するボタン — 計測 累積面積 5.073% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

getTotalLengthstrokeDashoffsetspinLoop
  // 1回の rAF ループでリングの opacity・チェックの opacity・チェックの描画長・ティントの opacity の
  // 4つを同じ進捗 p で同時に動かし、「形がつながって変わる」印象を作る。
  function morph(myGen, onDone) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var p = easeOutCubic(Math.min(1, (now - start) / DRAW_MS));
      ringWrap.style.opacity = String(1 - p);
      checkWrap.style.opacity = String(p);
      checkPath.style.strokeDashoffset = String(checkLen * (1 - p));
      tint.style.opacity = String(p * .5);
      if (p < 1) requestAnimationFrame(step); else onDone();
    }
    requestAnimationFrame(step);
  }

04失敗すると短く揺れて元のラベルに戻るボタン

回っていたスピナーはエラーに遭うとバツ印のアイコンに変わり、ボタン全体が左右に短く揺れ、揺れが収まると元のラベル文字に戻ります。ネットワークエラーで送信が失敗した際に再試行を促すのに使えます。

失敗すると短く揺れて元のラベルに戻るボタン
新しいタブで開く

04 · 失敗すると短く揺れて元のラベルに戻るボタン — 計測 累積面積 0.726% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

Math.sintranslateXaria-live
  // 計算層: 減衰サイン波 x(t) = A · e^(-decay·t) · sin(2π·freq·t) を毎フレーム計算し、
  // ボタン全体の translateX に適用する — 固定キーフレームではなく物理量として揺らす。
  function shake(myGen, ms, onDone) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var elapsed = now - start;
      var tSec = elapsed / 1000;
      var x = SHAKE_AMPLITUDE_PX * Math.exp(-SHAKE_DECAY_PER_S * tSec) * Math.sin(2 * Math.PI * SHAKE_FREQ_HZ * tSec);
      btn.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
      if (elapsed < ms) requestAnimationFrame(step);
      else { btn.style.transform = 'translateX(0px)'; if (onDone) onDone(); }
    }
    requestAnimationFrame(step);
  }

05ローディング中はアイコンだけ残して狭くなるボタン

ボタンを押すと文字ラベルが横に消えていき、丸いアイコン1つがようやく収まる幅まで狭くなり、作業が終わると再び元の幅に広がってラベルが戻ります。狭いツールバーやモバイル画面で場所を節約したいボタンに向いています。

ローディング中はアイコンだけ残して狭くなるボタン
新しいタブで開く

05 · ローディング中はアイコンだけ残して狭くなるボタン — 計測 累積面積 4.394% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

driveScalegetBoundingClientRectscaleX
  // 計算層: 現在描画されているボタンの実測幅(fullWidth)とアイコン1つ分の目標幅を比較して
  // collapsedRatio を求め、毎フレーム scale 値で btn を scaleX しながらアイコンには 1/scale の
  // 逆スケールをかけ、ボタンが狭くなる間もアイコン自体は潰れないようにする。
  function driveScale(myGen, fromScale, toScale, ms, onDone) {
    var fullWidth = btn.getBoundingClientRect().width / fromScale;
    var collapsedRatio = COLLAPSED_WIDTH_PX / fullWidth;
    var resolvedFrom = fromScale === 1 ? 1 : collapsedRatio;
    var resolvedTo = toScale === 1 ? 1 : collapsedRatio;
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var p = easeInOutQuad(Math.min(1, (now - start) / ms));
      var scale = resolvedFrom + (resolvedTo - resolvedFrom) * p;
      btn.style.transform = 'scaleX(' + scale.toFixed(4) + ')';
      icon.style.transform = 'scaleX(' + (1 / scale).toFixed(4) + ')';
      if (p < 1) requestAnimationFrame(step); else onDone();
    }
    requestAnimationFrame(step);
  }

06ラベルの代わりに点が3つ交互に大きくなるボタン

ボタンを押すと文字が消えた場所に小さな点が3つ並び、左から順に1つずつ大きくなっては小さくなることを繰り返して作業中であることを示します。チャット送信や短いAPI呼び出しのような素早い処理の待機表示に向いています。

ラベルの代わりに点が3つ交互に大きくなるボタン
新しいタブで開く

06 · ラベルの代わりに点が3つ交互に大きくなるボタン — 計測 累積面積 0.207% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pulseLoopMath.sinPHASE_OFFSET_RAD
  // 計算層: 点ごとに位相をずらしたサイン波 scale_i(t) = 1 + A·sin(2π·t/period − i·phaseOffset)
  // を毎フレーム計算する — animation-delay でずらすのではなく、各点を毎回直接計算する。
  function pulseLoop(myGen) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var t = now - start;
      for (var i = 0; i < dotEls.length; i++) {
        var s = 1 + AMPLITUDE * Math.sin((2 * Math.PI * t) / PULSE_PERIOD_MS - i * PHASE_OFFSET_RAD);
        dotEls[i].style.transform = 'scale(' + s.toFixed(3) + ')';
      }
      rafId = requestAnimationFrame(step);
    }
    rafId = requestAnimationFrame(step);
  }

07ローディング中に暗くなり押せなくなるロックボタン

一度押すと背景がわずかに暗くなり、その上に半透明の膜がかかって再度押しても反応しなくなり、その暗さがごくゆっくり明暗を繰り返して待機中であることを知らせます。二重クリックで同じリクエストが二度送られるのを防ぐのに使えます。

ローディング中に暗くなり押せなくなるロックボタン
新しいタブで開く

07 · ローディング中に暗くなり押せなくなるロックボタン — 計測 累積面積 3.318% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BASE_HZrequestAnimationFramearia-busy
  // 計算層: 残り時間(remaining)が減るほど振動数を BASE_HZ→MAX_HZ へ引き上げる。振動数が
  // 時間とともに変わるため sin(2π·f·t) をそのまま使えず、phase をフレームの差分で直接
  // 積分する(phase += 2π·f(now)·dt) — 残り時間が減るほど膜がより速く点滅する。
  function pulseLoop(myGen) {
    var last = null;
    var phase = 0;
    var loopStart = null;
    function step(now) {
      if (myGen !== gen) return;
      if (last === null) { last = now; loopStart = now; }
      var dt = now - last;
      last = now;
      var elapsed = now - loopStart;
      var remainingFrac = clamp01(1 - elapsed / COOLDOWN_MS);
      var freq = BASE_HZ + (MAX_HZ - BASE_HZ) * (1 - remainingFrac);
      phase += 2 * Math.PI * freq * (dt / 1000);
      var wave = (1 + Math.sin(phase)) / 2;
      film.style.opacity = String(.14 + .16 * wave);
      rafId = requestAnimationFrame(step);
    }
    rafId = requestAnimationFrame(step);
  }

08ローディング中にラベル文言が段階ごとに変わるボタン

ボタンを押すとラベルはある文言から始まり、一定時間ごとに次の文言へ上下にスライドして複数の段階を順に経ていき、単なるスピナーよりも今どの段階かを文字で伝えます。複数の段階を経る長い送信作業の進行案内に向いています。

ローディング中にラベル文言が段階ごとに変わるボタン
新しいタブで開く

08 · ローディング中にラベル文言が段階ごとに変わるボタン — 計測 累積面積 0.785% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

measureOffsetsoffsetTopslideTo
  // 計算層: 各段階文言のオフセットは推測せず offsetTop で実測する — トラック内で手前の
  // 文言が積み上がった実際の高さの分だけ押し上げるので、行の高さが変わってもずれない。
  function measureOffsets() {
    return stepEls.map(function (el) { return el.offsetTop; });
  }

  function fade(el, from, to, ms, myGen, onDone) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var p = Math.min(1, (now - start) / ms);
      el.style.opacity = String(from + (to - from) * easeInOutQuad(p));
      if (p < 1) requestAnimationFrame(step); else if (onDone) onDone();
    }
    requestAnimationFrame(step);
  }

09モーション削減時は点1つで止まるローディングボタン

システムでモーション削減が有効な場合、ボタンは回転するスピナーの代わりにラベルの横に静止した点を1つだけ置いたままにし、ローディングが終わる瞬間にだけその点を消して結果の状態に切り替えます。前庭障害やめまいに敏感なユーザーのための代替ローディング表示です。

モーション削減時は点1つで止まるローディングボタン
新しいタブで開く

09 · モーション削減時は点1つで止まるローディングボタン — 計測 累積面積 0.588% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

matchMediarole=switcharia-checked
  function setToggleUI() {
    toggle.setAttribute('aria-checked', simReduce ? 'true' : 'false');
    knob.style.transform = simReduce ? 'translateX(16px)' : 'translateX(0px)';
  }

  function applyToggle(next) {
    simReduce = next;
    setToggleUI();
  }

  // シミュレーショントグル — 訪問者は実際のOS設定に関係なく両方の状態を事前に見られる。
  toggle.addEventListener('click', function () {
    human();
    applyToggle(!simReduce);
  });

  if (osReduce) setToggleUI();

  // 実使用パス — 実際のOS値が変わると(ブラウザ・OS設定の変更)、シミュレーショントグルも追従する。
  function onOsChange(e) {
    osReduce = e.matches;
    applyToggle(osReduce);
  }
  if (mql.addEventListener) mql.addEventListener('change', onOsChange);

値がUIを変える場所 — 継続時間・揺れ幅・段階文言の切替間隔

04 — SHAKE_MS

この定数はパターン04がエラーに遭うたびにshake()が動き続ける時間を決めます。波の周波数や振幅ではなく、減衰するサイン波が元に戻るまでの猶予時間だけを変えます。

08 — STEP_INTERVAL_MS

パターン08のtick()が1つの段階文言をスライドさせてから次の文言に切り替えるまで待つ時間です。スライド自体の長さではなく、各文言がどれだけの間読める状態で留まるかを決めます。

02 — HOLD_AT_FULL_MS

パターン02が塗りつぶしをscaleX(1)まで満たした後、次の状態に移るまで留まる時間です。塗りつぶし自体のeaseOutCubicカーブとは関係なく、完了した状態がどれだけ表示され続けるかだけを変えます。

どこで壊れるか — アクセシビリティ・reduced-motion・二重クリック

9つのパターンはすべて同じ土台を共有しています。ボタンを押すとbtn.disabled = trueとaria-busy="true"が同時に設定され、更新されるステータステキストはrole="status"のaria-live="polite"領域の中にあるため、スクリーンリーダーは「保存中」「アップロード中」「処理中」といった状態の変化をフォーカスを移さなくても聞き取れます。btn.disabledだけでも2回目のクリックを防ぎ、タブ移動の対象からボタンを外しますが、パターン07はそのロックの上にさらに半透明の膜を重ねています。これは、単に無効化されているだけの状態は一見して気づきにくいことがあるためで、膜は待機中であることを技術的にだけでなく視覚的にも伝えます。パターン09だけはaria-checkedと連動したrole="switch"のトグルという2つ目の操作要素を持ちます。OSの設定変更を待つのではなく、訪問者が手動でreducedとanimatedの2つの状態を切り替えられるようにするためです。

  • 01 — ラベルからスピナーへのクロスフェードは省略され、スピナーがいきなり不透明度1で表示され、完了時もいきなりラベルが不透明度1に戻ります。途中のフェードはありません。
  • 02 — 塗りつぶしは掃くように伸びる代わりに「アップロード完了」の時点でいきなりscaleX(1)になり、その後1段階でフェードアウトします。
  • 03 — リングからチェックへのモーフは完全に省略され、リングは消え、チェックは最初から描き終わった状態で、ティントも最終値のまま表示されます。
  • 04 — 揺れそのものは起こらず、スピナーの代わりにバツ印のアイコンがそのまま表示され、エラー保持時間が終わるとラベルが戻ります。
  • 05 — 幅はイージングせず、1回のscaleXでCOLLAPSED_WIDTH_PXまでいきなり縮み、元の幅に戻るときも同じ方法です。
  • 06 — 3つの点は位相をずらしたサイン波でパルスする代わりに、固定の不透明度で現れたり消えたりします。
  • 07 — 暗い膜とロックアイコンは明暗をゆっくり繰り返すループの代わりに、保持された不透明度までいきなり切り替わります。
  • 08 — 段階文言はSTEP_STATUSの各項目に順番通り切り替わりますが、トラックはフレームごとに滑らかにスライドしません。runReducedが1回のsetTimeoutにまとめられず、タイマーループを維持している唯一のパターンです。各段階を順番に読む必要があるためです。
  • 09 — このパターンの前提そのものがreducedとanimatedの違いなので、reduced-motionのために何かが省略されることはありません。reduced状態はもともと2つの状態のうちの1つであり、デモ自体のトグルでOSの設定にかかわらず両方を確認できます。

上のaria連携とこのセクションで扱ったすべてのタイマーは、パスワードg7cf8ma6で開く下のzipに実際のコードのまま入っています。

3つのバリエーションとアクセシビリティ

バリエーション 変更した値 感触
エラー時の揺れの持続時間を延ばす 04 SHAKE_MS 520 → 900 エラー時の揺れの持続時間を延ばす(520ms→900ms)とボタンがより長く左右に揺れ、失敗をより強く感じさせます。
段階文言の切替間隔を狭める 08 STEP_INTERVAL_MS 450 → 260 段階文言の切替間隔を狭める(450ms→260ms)と全体のシーケンスが速く過ぎるようになり、各段階を読むというより進行感だけが残ります。
進捗の満杯後の停止時間を延ばす 02 HOLD_AT_FULL_MS 500 → 1400 進捗が満杯になった後の停止時間を延ばす(500ms→1400ms)と完了を目で確認する余裕が増えますが、全体の流れはその分遅く感じられます。

これら3つの定数が変えるのはタイミングだけで、上で説明したaria-busy・role="status"・aria-live="polite"の配線はパターン02・04・08のどこも変わりません。

ボタン ローディングの実装例はどこで見られる?

ボタン スピナーを実際に見る一番わかりやすい場所は、決済やファイルアップロードのフローです。フォームを送信してから目に見える形で待つボタンは、ほとんどの本番サイトが必ず持っているローディング状態の1つです。多くのコンポーネントライブラリやダッシュボードキットは上の9種のうち1つ、たいていスピナー切り替えか無効化による暗転だけを実装して終わりがちなので、複数を並べて見比べることが、どのタイミングとどの計算の一層が自分のフォームに合うかを判断する一番早い方法です。このページのコードもデモ用に簡略化したものではなく、実際に動くボタンからそのまま取り出したものなので、フォームのサーバー応答時間に合う状態をそのままコピーして使えます。

FAQ

なぜ9つのパターンすべてがaria-busyとdisabledを同時に設定するのですか?

役割が異なるからです。disabledは実際に2回目のクリックを止め、タブ移動の対象からボタンを外します。一方aria-busyは、その要素の中身が変化している最中であることを支援技術に伝えるだけです。どちらか一方だけでは不十分で、aria-busyだけならボタンは2回クリックできてしまいますし、disabledだけならラベルがこれから変わることをスクリーンリーダーに知らせる手段がありません。

パターン01の幅ロックはレイアウトシフトを引き起こしませんか?

いいえ、そのためにlockWidth()があります。ラベルがフェードアウトを始める前にgetBoundingClientRectでボタンの現在の描画幅を読み取り、インラインスタイルとして固定するため、ラベルより幅の狭いスピナーアイコンに置き換わってもボタンが狭くなって周囲の要素を巻き込んでレイアウトが崩れることはありません。

これらのローディング ボタンの例は実務のフォームにそのまま使えますか?

はい、1点だけ注意してください。このセット内のすべてのタイマーは、実際のネットワーク応答の代わりにsetTimeoutとrequestAnimationFrameで動いています。実際のfetchやフォーム送信のPromiseに置き換え、aria-busy/disabledの組み合わせとaria-liveのステータス領域をそのまま残せば、デモページの外でも同じように動作します。

出典: CSS トランジションの使用 · button: disabled · aria-busy · prefers-reduced-motion · CSS animation

関連記事: 【コピペOK】CSS ボタン アニメーション 9選(SCSS + React) · CSS ローディング スピナー 9選 — コピペでOK · css ボタン ホバー アニメーション 9選 — 受賞級の定番テク

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。