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

ログイン 失敗 ロック 9選 — 値が計算で絞られていく9つの方式

9つのうち1つはログインに失敗するたびに残り試行回数の値が1つずつ減り、1つは失敗が重なるほど次の試行までの待ち時間の値が倍々に増え、もう1つはロックが解けるまでの残り秒数の値が0に向かって減っていきます。「ログイン 失敗 ロック」で探している方に、9つの計算方式をそのまま見せます。

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

9つとも同じきっかけ、つまりボタンへの本物の click だけで動きます(08だけ失敗用・成功用の2つ、09はパネル全体がリスナーです)。タイマーやサイコロで次の状態を決めている項目は一つもなく、すべての値は決まった数式をカウンターに当てはめて出しているだけで、Math.random() はどこにも使っていません。サーバーもSMSも本物のキャプチャサービスもここにはなく、「ロック」も「キャプチャ」も「待ち時間」もすべてブラウザ内のローカル変数の再計算なので、以下の説明を「サーバー側でアカウントがロックされる」という意味には読まないでください — 画面の中だけで完結する計算です。01のパスワード欄は type="password" への切り替えではなく、CSSの -webkit-text-security: disc でマスクしているため、揺れのアニメーションを見せながらもパスワード欄らしい見た目を保っています。

9つのパターンの解剖 — どの値がいつ変わるか

01・03・04・07の4つは、1つのカウンターの値をそのまま画面表示に変換する方式です。01は残り試行回数の値を数字と揺れ幅の両方に使い、03は残り試行回数の値をSVGリングの stroke-dashoffset に使い、04は失敗回数の値がしきい値を超えるたびに固定リストからキャプチャの枠数を選び、07は積み上がった履歴リストの各行の不透明度をその行の順番から計算します。05・08の2つはパーセンテージを単純に補間するのではなく、スプリングを積分します — 剛性(SPRING_K)・減衰(SPRING_C)・質量(SPRING_M)から毎フレーム速度と位置を進めるので、バナーのスライドインも成功時のリセットも行き過ぎてから静止します。02と06は「待ち時間の値が0へ近づく」という見た目こそ似ていますが、02は失敗のたびに待ち時間の値が2倍に伸び、06は固定のロック時間から1秒ごとに1ずつ引かれるだけなので、指数バックオフと定額クールダウンほどの違いがあります。09だけは特殊で、同じ残り試行回数の値を2つのパネルで同時に減らし、片方だけトゥイーンを付け、もう片方は付けないことで、モーション削減の状態と通常状態を1回のクリックで並べて見せます。

パターン別コード

01残り試行回数の揺れ

失敗するたびに残り回数の値が1つ減り、その瞬間に数字が短く左右に揺れます。揺れ幅自体も数式になっていて、残り回数が減るほど揺れ幅が大きくなるので、ロック直前の最後の1回が一番強く揺れます。

残り試行回数の揺れ — ログインに失敗するたびに残り試行回数の値が減り、数字が一瞬揺れる方式
新しいタブで開く

01 · 残り試行回数の揺れ — ログインに失敗するたびに残り試行回数の値が減り、数字が一瞬揺れる方式 — 計測 累積面積 0.505% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_TRIESAMP_STEPSHAKE_MS
// 計算の層: 振幅 = 基準値 + (最大 - 残り) x 増加幅、減衰サイン = sin(t x 2π x 振動数) x (1 - t)
function shake() {
  var amp = AMP_BASE + (MAX_TRIES - remaining) * AMP_STEP;
  var start = null;
  shaking = true;
  function frame(ts) {
    if (start === null) start = ts;
    var t = Math.min(1, (ts - start) / SHAKE_MS);
    var x = amp * Math.sin(t * Math.PI * 2 * SHAKE_FREQ) * (1 - t);
    countEl.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
    if (t < 1) requestAnimationFrame(frame);
    else { countEl.style.transform = 'translateX(0)'; shaking = false; }
  }
  requestAnimationFrame(frame);
}

function fail() {
  if (shaking) return;
  remaining = remaining > 0 ? remaining - 1 : MAX_TRIES;
  paint();
  shake();
}

数値が示すこと

23フレーム中、01は面積0.51%・強度115.7・動いたフレーム14で計測されており、画面全体ではなく数字まわりだけが短く揺れる方式だとわかります。

02再試行待ち倍増

失敗回数が増えるたびに、待ち時間の値が直前の値の2倍になります。カウントダウン自体は1秒ごとではなく requestAnimationFrame で毎フレーム進めているため、バーが刻み無くなめらかに減っていきます。

再試行待ち倍増 — 失敗するたびに次の試行までの残り時間の値が2倍ずつ増える方式
新しいタブで開く

02 · 再試行待ち倍増 — 失敗するたびに次の試行までの残り時間の値が2倍ずつ増える方式 — 計測 累積面積 9.492% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

BACKOFF_BASE_SECCAP_SECDEMO_TICK_MS
function waitSecondsOf(n) {
  return Math.min(CAP_SEC, BACKOFF_BASE_SEC * Math.pow(2, n));
}

function countdown(totalSec) {
  locked = true;
  btn.disabled = true;
  var start = null;
  var totalMs = totalSec * DEMO_TICK_MS;
  function frame(ts) {
    if (start === null) start = ts;
    var elapsed = ts - start;
    var left = Math.max(0, totalSec - elapsed / DEMO_TICK_MS);
    waitEl.textContent = '次の試行まで ' + Math.ceil(left) + '秒';
    fill.style.transform = 'scaleX(' + Math.max(0, left / totalSec) + ')';
    if (elapsed < totalMs) { rafId = requestAnimationFrame(frame); }
    else {
      waitEl.textContent = '待機なし';
      fill.style.transform = 'scaleX(0)';
      locked = false;
      btn.disabled = false;
    }
  }
  rafId = requestAnimationFrame(frame);
}

数値が示すこと

02は23フレーム全体を通して面積9.49%・強度127.8で計測されており、待ち時間中はバーの塗りとテキストがほぼ毎フレーム再描画されていることになります。

03ロックまでの残りリング

残り試行回数の値が減るほど、円形の進捗値も一緒に減っていきます。リングのオフセットは円周から残り試行の割合を引くだけの単純な円弧計算で、別のパーセント変数を持たずに同期しています。

ロックまでの残りリング — 残り試行回数の値を円形の進捗値として表す方式
新しいタブで開く

03 · ロックまでの残りリング — 残り試行回数の値を円形の進捗値として表す方式 — 計測 累積面積 2.46% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_TRIESRCIRC
// 定数: 最大試行回数・リング半径(SVG viewBox座標)
var MAX_TRIES = 5;
var R = 40;
var CIRC = 2 * Math.PI * R; // 円周
prog.style.strokeDasharray = CIRC.toFixed(2);

var remaining = MAX_TRIES;

// 計算の層: リングのオフセット = 円周 x (1 - 残り/最大) — stroke-dashoffset の算術
function paint() {
  countEl.textContent = String(remaining);
  var offset = CIRC * (1 - remaining / MAX_TRIES);
  prog.style.strokeDashoffset = offset.toFixed(2);
  root.classList.toggle('alr--locked', remaining <= 0);
}

function fail() {
  if (remaining <= 0) { remaining = MAX_TRIES; paint(); return; }
  remaining -= 1;
  paint();
}

数値が示すこと

03は面積2.46%・強度150.6・23フレーム中22フレームで計測されており、変化はカード全体ではなく細いリングの線に集中しています。

04キャプチャ難易度段階

失敗回数の値が定めたしきい値を超えるたびに、キャプチャの枠数の値が1段階ずつ増えます。段階のリスト自体は4段階に固定されているため、グリッドはその4種類の枠数のどれかにしかなりません。

キャプチャ難易度段階 — 失敗回数がしきい値の値を超えるたびにキャプチャの枠数の値が増える方式
新しいタブで開く

04 · キャプチャ難易度段階 — 失敗回数がしきい値の値を超えるたびにキャプチャの枠数の値が増える方式 — 計測 累積面積 16.206% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

STAGE_THRESHOLDCELL_STEPSAUTOPLAY_RESET_TICKS
// 定数: キャプチャ段階を上げる失敗回数・段階ごとの枠数(グリッドが揃う平方数)・自動再生リセット周期
var STAGE_THRESHOLD = 2;
var CELL_STEPS = [0, 4, 9, 16]; // 段階0=なし、1=2x2、2=3x3、3(上限)=4x4
var AUTOPLAY_RESET_TICKS = 7;

var fails = 0;

function render() {
  // 計算の層: 段階 = min(上限, floor(失敗÷しきい値))、枠数 = CELL_STEPS[段階]
  var stage = Math.min(CELL_STEPS.length - 1, Math.floor(fails / STAGE_THRESHOLD));
  var cells = CELL_STEPS[stage];
  var cols = Math.round(Math.sqrt(cells)) || 0;
  failsEl.textContent = String(fails);
  stageEl.textContent = stage === 0 ? 'キャプチャなし' : ('キャプチャ' + cols + '×' + cols);
  grid.style.setProperty('--cols', cols || 1);
  grid.innerHTML = '';
  for (var i = 0; i < cells; i++) {
    var c = document.createElement('div');
    c.className = 'ace__cell';
    grid.appendChild(c);
  }
}

数値が示すこと

04はここで最大の面積16.21%・強度142.7・18フレームで計測されており、段階が変わるフレームでは枠グリッドがまるごと生成・破棄されるためです。

05警告バナーのスライド

繰り返し失敗回数の値がしきい値を超えた瞬間、バナーの位置の値が画面外から画面内へ移動します。位置と速度は SPRING_K・SPRING_C・SPRING_M から毎フレーム積分されるため、目標に着いた瞬間に止まらず、少し行き過ぎてから静止します。

警告バナーのスライド — 繰り返しの失敗がしきい値を超えると、バナーの位置の値が画面上から下りてくる方式
新しいタブで開く

05 · 警告バナーのスライド — 繰り返しの失敗がしきい値を超えると、バナーの位置の値が画面上から下りてくる方式 — 計測 累積面積 8.387% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

THRESHOLDSPRING_KSPRING_C
var SPRING_K = 180;
var SPRING_C = 18;
var SPRING_M = 1;

var fails = 0;
var pos = -60;   // バナー位置(%、負の値=画面外の上)
var vel = 0;
var target = -60;
var rafId = null;

// 計算の層: 半陰的オイラー法によるスプリング積分 — 速度 += (-k x 変位 - c x 速度) / 質量 x dt、位置 += 速度 x dt
function tick(ts, last) {
  var dt = Math.min(32, ts - last) / 1000;
  var disp = pos - target;
  var acc = (-SPRING_K * disp - SPRING_C * vel) / SPRING_M;
  vel += acc * dt;
  pos += vel * dt;
  banner.style.transform = 'translateY(' + pos.toFixed(2) + '%)';
  if (Math.abs(disp) > 0.3 || Math.abs(vel) > 0.5) {
    rafId = requestAnimationFrame(function (t) { tick(t, ts); });
  } else {
    pos = target;
    banner.style.transform = 'translateY(' + pos.toFixed(2) + '%)';
  }
}

数値が示すこと

05は面積8.39%・強度128.2ですが、動いたのは23フレーム中12フレームだけで、スプリングが早く静止するぶん動きが短い区間に集中していることになります。

06ロック解除カウントダウン

ロックされた瞬間から残り秒数の値が1ずつ減っていき、0になると再試行できる状態に切り替わります。この1秒ごとの減算は requestAnimationFrame ではなく setInterval で進んでいて、値が1ティックに1回しか変わらないため、それで十分だからです。

ロック解除カウントダウン — ロック後、再試行できるまでの残り秒数の値が減っていく方式
新しいタブで開く

06 · ロック解除カウントダウン — ロック後、再試行できるまでの残り秒数の値が減っていく方式 — 計測 累積面積 3.717% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

LOCK_SECONDSDEMO_TICK_MSPULSE_SCALE
function paint() {
  numEl.textContent = String(remaining);
  root.classList.toggle('acc--open', remaining <= 0);
  msgEl.textContent = remaining <= 0 ? '今すぐ再試行できます' : 'ロック解除までの残り秒数';
  btn.disabled = remaining > 0;
}

function start() {
  remaining = LOCK_SECONDS;
  paint();
  if (timerId) clearInterval(timerId);
  timerId = setInterval(function () {
    remaining -= 1;
    pulse();
    if (remaining <= 0) { clearInterval(timerId); timerId = null; }
    paint();
  }, DEMO_TICK_MS);
}

数値が示すこと

06は9項目中もっとも強度が高く23フレーム中171.7で、面積は3.72%です。数字のパルス拡大とボタンのdisabled・メッセージ切り替えが同じフレームに重なっているためです。

07試行履歴フェードリスト

新しい試行記録が1つ加わるたびに、それ以前の記録の不透明度の値が1段階ずつ下がります。下がり幅は各行自身のリスト内順番から出す一定の倍率で、下限を設けているため古い行も完全には消えません。

試行履歴フェードリスト — 新しい試行が積まれるたびに古い記録ほど不透明度の値が下がる方式
新しいタブで開く

07 · 試行履歴フェードリスト — 新しい試行が積まれるたびに古い記録ほど不透明度の値が下がる方式 — 計測 累積面積 39.983% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_ENTRIESOPACITY_STEPMIN_OPACITY
var MIN_OPACITY = 0.16;

var SEQ = ['失敗', '失敗', '成功', '失敗', '失敗', '失敗', '成功', '失敗'];
var seqIdx = 0;
var entries = []; // 最新がindex 0

function fmtTime(n) {
  var m = String(10 + n % 50).padStart(2, '0');
  return '10:' + m;
}

// 計算の層: i番目(0=最新)の項目の不透明度 = max(下限, 1 − i×STEP)
function render() {
  list.innerHTML = '';
  for (var i = 0; i < entries.length; i++) {
    var e = entries[i];
    var op = Math.max(MIN_OPACITY, 1 - i * OPACITY_STEP);
    var li = document.createElement('li');
    li.className = 'ahf__row' + (e.result === '失敗' ? ' ahf__row--fail' : ' ahf__row--ok');
    li.style.opacity = op.toFixed(2);
    li.innerHTML = '<span class="ahf__time">' + e.time + '</span>' +
      '<span class="ahf__result">' + e.result + '</span>';
    list.appendChild(li);
  }
}

数値が示すこと

07は9項目中もっとも面積が大きく39.98%で計測されており、新しい行が1つ増えるたびに既存の行すべての不透明度が再計算されるため、リスト全体が動くことになります。

08成功時リセット

失敗が積み重なっている途中で一度成功すると、回数の値がすぐ最初の値に戻ります。このリセットにも05と同じ形のスプリング積分が使われていますが、SPRING_K・SPRING_C の値自体は05とは別物なので、数字は1を少し超えてから静止します。

成功時リセット — 一度でも成功すると失敗回数の値がすぐに0へ戻る方式
新しいタブで開く

08 · 成功時リセット — 一度でも成功すると失敗回数の値がすぐに0へ戻る方式 — 計測 累積面積 0.79% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_SHOWNSPRING_KSPRING_C
function scaleTick(ts, last) {
  var dt = Math.min(32, ts - last) / 1000;
  var disp = scalePos - scaleTarget;
  var acc = (-SPRING_K * disp - SPRING_C * scaleVel) / SPRING_M;
  scaleVel += acc * dt;
  scalePos += scaleVel * dt;
  countEl.style.transform = 'scale(' + scalePos.toFixed(3) + ')';
  if (Math.abs(disp) > 0.004 || Math.abs(scaleVel) > 0.01) {
    rafId = requestAnimationFrame(function (t) { scaleTick(t, ts); });
  } else {
    scalePos = scaleTarget;
    countEl.style.transform = 'scale(1)';
  }
}

function bounce() {
  if (rafId) cancelAnimationFrame(rafId);
  scalePos = 1.5;
  scaleVel = 0;
  scaleTarget = 1;
  rafId = requestAnimationFrame(function (ts) { scaleTick(ts, ts - 16); });
}

数値が示すこと

08は面積0.79%・強度133.4・10フレームと9項目中もっとも小さい部類で、コンポーネント規模の方式らしく、面積の広さよりスプリングの行き過ぎ具合そのものに見どころがあります。

09モーション削減の即時更新

モーション削減設定がオンだと、値は徐々に変わらず次の状態へ直接切り替わります。左右のパネルは同じクリックで同じカウンターを減らしていますが、左だけ shakeA() でトゥイーンし、右はトゥイーンなしでそのまま数字を書き換えます。

モーション削減の即時更新 — モーション削減設定時に値が動きなしで次の状態へ直接変わる方式
新しいタブで開く

09 · モーション削減の即時更新 — モーション削減設定時に値が動きなしで次の状態へ直接変わる方式 — 計測 累積面積 0.868% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

MAX_TRIESAMPSHAKE_MS
function step() {
  remaining = remaining > 0 ? remaining - 1 : MAX_TRIES;
  a.textContent = String(remaining);
  shakeA();
  // モーション削減パネル: 同じ値がトゥイーン無しでその場で切り替わる
  b.textContent = String(remaining);
}

root.addEventListener('click', function () {
  root.classList.remove('is-demo');
  stopAutoplay();
  step();
});

var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(step, 420); }
else { autoTimer = setInterval(function () { remaining = remaining > 0 ? remaining - 1 : MAX_TRIES; a.textContent = b.textContent = String(remaining); }, 420); }

数値が示すこと

09は面積0.87%・強度154.0・20フレームで、08と同じく小さな数字の切り替えだけが動く方式であることが数字にも表れています。

値がパターンを変える基準 — しきい値回数・倍率・待ち時間の基準

バックオフ初回待ち

02の BACKOFF_BASE_SEC は1から4になります。待ち時間は BACKOFF_BASE_SEC x 2^n で決まるため、基準値を上げても倍になっていく速さそのものは変わらず、変わるのは倍増が始まる前の最初の待ち時間の長さだけです。

キャプチャ格上げのタイミング

04の STAGE_THRESHOLD は2から5になります。これは次のキャプチャ段階へ上がるまでに必要な失敗回数を変えるだけで、その段階が持つ枠数のリスト(CELL_STEPS)自体は変わりません — しきい値が上がるほど、同じ4種類の枠数の中で1段階あたり許される失敗が増えるということです。

リセットのスプリング反動

08の SPRING_K は220から90になります。剛性を下げると同じスプリング積分でも静止までが遅くなり、1を超えて振れる幅も広がります。この値は05自身の SPRING_K(180)とは別の値なので、2つのバナーは定数を共有していません。

9つのどれもレイアウトそのものは決めません。コピペで使えるログインフォームUIデザイン9選(206)はカードとその中の入力欄がどこに置かれるかという軸を扱い、ログインUIデザインパターン9選(コピペ可)(341)はログインまわりの画面から画面への遷移という一段上の軸を扱い、パスワード強度メーターUI: 計算パターン9選(356)は入力中の値がその場でスコアを持つという軸を扱います。ここで扱う アカウント ロック 解除 時間が画面全体のうち一部分だけなら、あわせて見る価値があります。9つ分のソースをまとめたzipは nmr9ytx5 で開けます。

どこで壊れるか — リロード乱用・複数タブ同時試行・モーション削減

リロード乱用

9項目のどれも localStorage や sessionStorage など、リロードをまたいで残る場所には何も書き込んでいません。カウンター・待ち時間・ロック状態はすべてただのJS変数なので、タブを再読み込みすれば9つとも最初の定数へ戻ります。ログイン 失敗 ロック 時間をページのメモリだけで管理する方式は、こうして「リロードすれば元通り」になる弱点がそのまま見える題材でもあります。

複数タブ同時試行

同じ項目を別タブで開くと、まったく独立したコピーが動き出します。BroadcastChannel も共有ワーカーもstorageイベントも2つのタブをつなげていないため、2つのタブが同じ残り試行回数やバックオフ待ちを、互いに気づかないまま同時に進めることになります。

モーション削減

9項目とも prefers-reduced-motion: reduce が制御しているのは、デモを自動でループさせる自動再生タイマー(autoTimer)が自分から動き出すかどうかだけです。本物のクリックであれば、そのOS設定に関係なく fail()・render()・tick()・scaleTick() は毎回最後まで実行されます。09だけは例外で、通常のトゥイーンとモーション削減の即時切り替えを1回のクリックで同時に並べて見せるよう最初から作られています。各項目のSCSSには同じメディアクエリの中で再試行ボタン自身のホバー transition だけを外す記述も別にありますが、これは上で説明したJS駆動のカウンターやリング、スプリングのアニメーションとは無関係です。

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

バリエーション 変わるもの 変わらないもの
バックオフ初回待ち BACKOFF_BASE_SEC 1 → 4 倍増の比率そのもの
キャプチャ格上げのタイミング STAGE_THRESHOLD 2 → 5 固定の CELL_STEPS 枠数
リセットのスプリング反動 SPRING_K 220 → 90 05自身のスプリング定数

9つのマークアップのどこにも aria-* 属性・role・tabindex は付いていません。残り試行回数が減ったことやロック解除カウントダウンをスクリーンリーダーへ知らせるライブリージョンも無いため、ここから先を実務で使うなら追加するのは使う側の役目です。素のまま使えているのは、01〜08がボタン要素 <button type="button"> を使っている点(08だけ失敗用・成功用の2個)、09だけは .arm パネル全体へのクリックリスナーでボタン要素を持たない点、02と06はボタンのスタイルだけでなく disabled 属性そのものを待ち時間・ロック中に付けている点です。この disabled の切り替えが、06の アカウント ロック 解除 時間の表示が0になった瞬間にメッセージごと元へ戻るところまで、9つの中でもっとも直接的にロック状態を示している部分になります。

FAQ

02の待ち時間は無限に倍増し続けますか

いいえ。waitSecondsOf() が Math.min で CAP_SEC を上限にしているため、一定の失敗回数を超えると待ち時間はその上限で止まり、それ以上は倍にならなくなります。

06だけ setInterval で05・08は requestAnimationFrame なのはなぜですか

06の残り秒数の値は1ティックに1回しか変わらないので setInterval だけで十分です(数字が変わるたびに鳴らす短いパルス演出だけは独自のフレームループを持っています)。05と08はスプリングの位置と速度を毎フレーム再計算しないと滑らかに見えないため、動き全体を requestAnimationFrame に乗せています。

これが本物の 総当たり攻撃 対策の実装例になりますか

いいえ、これだけでは実装例にはなりません。ここにある値はすべてブラウザの中だけで再計算されていて、サーバーもアカウントDBも本物の総当たり攻撃対策も背後にはないため、9つはあくまで調整して使うクライアント側の計算とモーションの層として見てください。

Sources: MDN — setInterval · MDN — requestAnimationFrame · MDN — prefers-reduced-motion

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

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