ログイン 失敗 ロック 9選 — 値が計算で絞られていく9つの方式
9つのうち1つはログインに失敗するたびに残り試行回数の値が1つずつ減り、1つは失敗が重なるほど次の試行までの待ち時間の値が倍々に増え、もう1つはロックが解けるまでの残り秒数の値が0に向かって減っていきます。「ログイン 失敗 ロック」で探している方に、9つの計算方式をそのまま見せます。
01 auth-attempt-counter-shake
02 auth-attempt-backoff-timer
03 auth-attempt-lockout-ring
04 auth-attempt-captcha-escalate
05 auth-attempt-alert-banner-slide
06 auth-attempt-cooldown-countdown
07 auth-attempt-history-list-fade
08 auth-attempt-success-reset
09 auth-attempt-reduced-motion-static
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 残り試行回数の揺れ — ログインに失敗するたびに残り試行回数の値が減り、数字が一瞬揺れる方式
- 02 再試行待ち倍増 — 失敗するたびに次の試行までの残り時間の値が2倍ずつ増える方式
- 03 ロックまでの残りリング — 残り試行回数の値を円形の進捗値として表す方式
- 04 キャプチャ難易度段階 — 失敗回数がしきい値の値を超えるたびにキャプチャの枠数の値が増える方式
- 05 警告バナーのスライド — 繰り返しの失敗がしきい値を超えると、バナーの位置の値が画面上から下りてくる方式
- 06 ロック解除カウントダウン — ロック後、再試行できるまでの残り秒数の値が減っていく方式
- 07 試行履歴フェードリスト — 新しい試行が積まれるたびに古い記録ほど不透明度の値が下がる方式
- 08 成功時リセット — 一度でも成功すると失敗回数の値がすぐに0へ戻る方式
- 09 モーション削減の即時更新 — モーション削減設定時に値が動きなしで次の状態へ直接変わる方式
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回が一番強く揺れます。

<main class="stage">
<div class="acs is-demo" id="acs">
<div class="acs__card">
<div class="acs__row">
<label class="acs__field"><input class="acs__input" id="acsUser" type="text" placeholder="ID" autocomplete="off" spellcheck="false"></label>
<label class="acs__field"><input class="acs__input acs__input--pw" id="acsPass" type="text" placeholder="パスワード" autocomplete="off" spellcheck="false"></label>
</div>
<button class="acs__btn" id="acsBtn" type="button">ログイン</button>
<div class="acs__readout">
<span class="acs__label">残り試行</span>
<span class="acs__count" id="acsCount">5</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 흔들림 진폭 = 기준값 + (최대시도 − 남은시도)×증가폭, 감쇠 사인 곡선(index.html 참조)
@include stage($stage-yellow);
.acs {
width: 100%;
max-width: 300px;
display: grid;
gap: $sp-3;
}
.acs__card {
display: grid;
gap: $sp-3;
}
.acs__row {
display: grid;
gap: $sp-2;
}
.acs__field { display: block; }
.acs__input {
width: 100%;
box-sizing: border-box;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
border: 0;
background: rgba($stage-ink, .08);
color: $stage-ink;
font: 500 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
&::placeholder { color: rgba($stage-ink, .48); }
&:focus { outline: 2px solid $stage-ink; outline-offset: 2px; }
}
.acs__input--pw { -webkit-text-security: disc; }
.acs__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
.acs__readout {
display: flex;
align-items: baseline;
justify-content: center;
gap: $sp-2;
}
.acs__label {
font: 500 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .64);
}
.acs__count {
display: inline-block;
font: 800 28px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
font-variant-numeric: tabular-nums;
}
.acs__count--low { color: $error; }
@media (max-width: 340px) {
.acs { max-width: 220px; }
.acs__count { font-size: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.acs__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.acs {
width: 100%;
max-width: 300px;
display: grid;
gap: 12px;
}
.acs__card {
display: grid;
gap: 12px;
}
.acs__row {
display: grid;
gap: 8px;
}
.acs__field {
display: block;
}
.acs__input {
width: 100%;
box-sizing: border-box;
padding: 8px 12px;
border-radius: 8px;
border: 0;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font: 500 14px/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.acs__input::placeholder {
color: rgba(23, 20, 26, 0.48);
}
.acs__input:focus {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.acs__input--pw {
-webkit-text-security: disc;
}
.acs__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.acs__btn:active {
transform: scale(0.96);
}
.acs__readout {
display: flex;
align-items: baseline;
justify-content: center;
gap: 8px;
}
.acs__label {
font: 500 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.64);
}
.acs__count {
display: inline-block;
font: 800 28px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.acs__count--low {
color: #c2361a;
}
@media (max-width: 340px) {
.acs {
max-width: 220px;
}
.acs__count {
font-size: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.acs__btn {
transition: none;
}
}(function () {
var root = document.getElementById('acs');
var btn = document.getElementById('acsBtn');
var countEl = document.getElementById('acsCount');
// 상수: 최대 시도 횟수·흔들림 진폭 기준값·진폭 증가폭·흔들림 지속시간·진동 횟수
var MAX_TRIES = 5;
var AMP_BASE = 3; // px — 첫 失敗 때 흔들림 폭
var AMP_STEP = 2; // px — 남은 횟수가 줄 때마다 늘어나는 폭
var SHAKE_MS = 480;
var SHAKE_FREQ = 4; // 진동 횟수
var remaining = MAX_TRIES;
var shaking = false;
function paint() {
countEl.textContent = String(remaining);
countEl.classList.toggle('acs__count--low', remaining <= 1);
}
// 계산 겹: 진폭 = 기준값 + (최대-남은)×증가폭, 감쇠 사인 = sin(t·2π·진동수)×(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();
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
fail();
});
paint();
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(fail, 420); }
})();import { useEffect, useRef } from "react";
import styles from "./AuthAttemptCounterShake.module.scss";
type Box<T> = { current: T };
export default function AuthAttemptCounterShake() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLButtonElement | null;
const countEl = root.querySelector(`.${styles.count}`) as HTMLElement | null;
if (!btn || !countEl) return;
// 상수: 최대 시도 횟수·흔들림 진폭 기준값·진폭 증가폭·흔들림 지속시간·진동 횟수
const MAX_TRIES = 5;
const AMP_BASE = 3;
const AMP_STEP = 2;
const SHAKE_MS = 480;
const SHAKE_FREQ = 4;
let remaining = MAX_TRIES;
let shaking = false;
function paint() {
countEl!.textContent = String(remaining);
countEl!.classList.toggle(styles["count--low"], remaining <= 1);
}
// 계산 겹: 진폭 = 기준값 + (최대-남은)×증가폭, 감쇠 사인 = sin(t·2π·진동수)×(1-t)
function shake() {
const amp = AMP_BASE + (MAX_TRIES - remaining) * AMP_STEP;
let start: number | null = null;
shaking = true;
function frame(ts: number) {
if (start === null) start = ts;
const t = Math.min(1, (ts - start) / SHAKE_MS);
const 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();
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
fail();
}
btn.addEventListener("click", onClick);
paint();
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(fail, 420); }
return () => {
stopAutoplay();
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.acs} ${styles["is-demo"]}`}>
<div className={styles.card}>
<div className={styles.row}>
<label className={styles.field}>
<input className={styles.input} type="text" placeholder="아이디" autoComplete="off" spellCheck={false} />
</label>
<label className={styles.field}>
<input className={`${styles.input} ${styles["input--pw"]}`} type="text" placeholder="비밀번호" autoComplete="off" spellCheck={false} />
</label>
</div>
<button className={styles.btn} type="button">로그인</button>
<div className={styles.readout}>
<span className={styles.label}>남은 시도</span>
<span className={styles.count}>5</span>
</div>
</div>
</div>
);
}01 · 残り試行回数の揺れ — ログインに失敗するたびに残り試行回数の値が減り、数字が一瞬揺れる方式 — 計測 累積面積 0.505% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 計算の層: 振幅 = 基準値 + (最大 - 残り) 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 で毎フレーム進めているため、バーが刻み無くなめらかに減っていきます。

<main class="stage">
<div class="abt is-demo" id="abt">
<div class="abt__card">
<div class="abt__row">
<span class="abt__label">失敗 <b id="abtFails">0</b>回</span>
<span class="abt__wait" id="abtWait">待機なし</span>
</div>
<div class="abt__track"><div class="abt__fill" id="abtFill"></div></div>
<button class="abt__btn" id="abtBtn" type="button">ログイン試行</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 대기초 = min(CAP, BACKOFF_BASE×2^실패횟수) — index.html 참조. 막대는 남은비율로 scaleX
@include stage($stage-orange);
.abt {
width: 100%;
max-width: 300px;
}
.abt__card {
display: grid;
gap: $sp-3;
}
.abt__row {
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
.abt__label b { font-size: 16px; font-variant-numeric: tabular-nums; }
.abt__wait {
font-variant-numeric: tabular-nums;
color: $subject-cream;
}
.abt__track {
position: relative;
height: $sp-3;
border-radius: $r-sm;
background: rgba(23, 20, 26, .28);
overflow: hidden;
}
.abt__fill {
position: absolute;
inset: 0;
border-radius: $r-sm;
background: $stage-ink;
transform: scaleX(0);
transform-origin: right center;
}
.abt__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: opacity $dur-instant $ease-out;
&:disabled { opacity: .5; cursor: not-allowed; }
}
@media (max-width: 340px) {
.abt { max-width: 220px; }
.abt__row { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.abt__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.abt {
width: 100%;
max-width: 300px;
}
.abt__card {
display: grid;
gap: 12px;
}
.abt__row {
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.abt__label b {
font-size: 16px;
font-variant-numeric: tabular-nums;
}
.abt__wait {
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.abt__track {
position: relative;
height: 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.28);
overflow: hidden;
}
.abt__fill {
position: absolute;
inset: 0;
border-radius: 8px;
background: #17141a;
transform: scaleX(0);
transform-origin: right center;
}
.abt__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: opacity 140ms ease-out;
}
.abt__btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 340px) {
.abt {
max-width: 220px;
}
.abt__row {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.abt__btn {
transition: none;
}
}(function () {
var root = document.getElementById('abt');
var btn = document.getElementById('abtBtn');
var failsEl = document.getElementById('abtFails');
var waitEl = document.getElementById('abtWait');
var fill = document.getElementById('abtFill');
// 상수: 지수 백오프 = BACKOFF_BASE_SEC × 2^실패횟수(秒), CAP_SEC 에서 증가를 멈춘다
var BACKOFF_BASE_SEC = 1;
var CAP_SEC = 16;
var DEMO_TICK_MS = 90; // 데모 안에서 "1秒"를 표현하는 실제 ms(가속)
var fails = 0;
var locked = false;
var rafId = null;
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);
}
function fail() {
if (locked) return;
fails += 1;
failsEl.textContent = String(fails);
countdown(waitSecondsOf(fails));
}
function reset() {
if (rafId) cancelAnimationFrame(rafId);
fails = 0;
locked = false;
btn.disabled = false;
failsEl.textContent = '0';
waitEl.textContent = '待機なし';
fill.style.transform = 'scaleX(0)';
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
fail();
});
var autoTimer = null;
var stage = 0;
function autoTick() {
if (locked) return;
if (stage >= 4) { reset(); stage = 0; return; }
fail();
stage += 1;
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(autoTick, 260); }
})();import { useEffect, useRef } from "react";
import styles from "./AuthAttemptBackoffTimer.module.scss";
type Box<T> = { current: T };
export default function AuthAttemptBackoffTimer() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLButtonElement | null;
const failsEl = root.querySelector(`.${styles.fails}`) as HTMLElement | null;
const waitEl = root.querySelector(`.${styles.wait}`) as HTMLElement | null;
const fill = root.querySelector(`.${styles.fill}`) as HTMLElement | null;
if (!btn || !failsEl || !waitEl || !fill) return;
// 상수: 지수 백오프 = BACKOFF_BASE_SEC × 2^실패횟수(초), CAP_SEC 에서 증가를 멈춘다
const BACKOFF_BASE_SEC = 1;
const CAP_SEC = 16;
const DEMO_TICK_MS = 90;
let fails = 0;
let locked = false;
let rafId: number | null = null;
function waitSecondsOf(n: number) {
return Math.min(CAP_SEC, BACKOFF_BASE_SEC * Math.pow(2, n));
}
function countdown(totalSec: number) {
locked = true;
btn!.disabled = true;
let start: number | null = null;
const totalMs = totalSec * DEMO_TICK_MS;
function frame(ts: number) {
if (start === null) start = ts;
const elapsed = ts - start;
const 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);
}
function fail() {
if (locked) return;
fails += 1;
failsEl!.textContent = String(fails);
countdown(waitSecondsOf(fails));
}
function reset() {
if (rafId !== null) cancelAnimationFrame(rafId);
fails = 0;
locked = false;
btn!.disabled = false;
failsEl!.textContent = "0";
waitEl!.textContent = "대기 없음";
fill!.style.transform = "scaleX(0)";
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
fail();
}
btn.addEventListener("click", onClick);
let autoTimer: number | null = null;
let stage = 0;
function autoTick() {
if (locked) return;
if (stage >= 4) { reset(); stage = 0; return; }
fail();
stage += 1;
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 260); }
return () => {
stopAutoplay();
if (rafId !== null) cancelAnimationFrame(rafId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.abt} ${styles["is-demo"]}`}>
<div className={styles.card}>
<div className={styles.row}>
<span className={styles.label}>실패 <b className={styles.fails}>0</b>회</span>
<span className={styles.wait}>대기 없음</span>
</div>
<div className={styles.track}><div className={styles.fill} /></div>
<button className={styles.btn} type="button">로그인 시도</button>
</div>
</div>
);
}02 · 再試行待ち倍増 — 失敗するたびに次の試行までの残り時間の値が2倍ずつ増える方式 — 計測 累積面積 9.492% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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ロックまでの残りリング
残り試行回数の値が減るほど、円形の進捗値も一緒に減っていきます。リングのオフセットは円周から残り試行の割合を引くだけの単純な円弧計算で、別のパーセント変数を持たずに同期しています。

<main class="stage">
<div class="alr is-demo" id="alr">
<div class="alr__ringwrap">
<svg class="alr__ring" viewBox="0 0 100 100" width="120" height="120">
<circle class="alr__track" cx="50" cy="50" r="40"></circle>
<circle class="alr__prog" id="alrProg" cx="50" cy="50" r="40"></circle>
</svg>
<div class="alr__mid">
<span class="alr__count" id="alrCount">5</span>
<span class="alr__label">残り試行</span>
</div>
</div>
<button class="alr__btn" id="alrBtn" type="button">ログイン試行</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: stroke-dashoffset = 둘레×(1 − 남은시도/최대시도) — index.html 참조
@include stage($stage-paper);
.alr {
display: grid;
justify-items: center;
gap: $sp-4;
}
.alr__ringwrap {
position: relative;
display: grid;
place-items: center;
}
.alr__ring {
transform: rotate(-90deg);
}
.alr__track {
fill: none;
stroke: rgba($stage-ink, .12);
stroke-width: 8;
}
.alr__prog {
fill: none;
stroke: $subject-blue-deep;
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dashoffset $dur-base $ease-out, stroke $dur-base $ease-out;
}
.alr--locked .alr__prog { stroke: $error; }
.alr__mid {
position: absolute;
display: grid;
justify-items: center;
}
.alr__count {
font: 800 30px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
font-variant-numeric: tabular-nums;
}
.alr__label {
font: 500 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .6);
}
.alr__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.alr__ring { width: 92px; height: 92px; }
.alr__count { font-size: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.alr__prog { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.alr {
display: grid;
justify-items: center;
gap: 16px;
}
.alr__ringwrap {
position: relative;
display: grid;
place-items: center;
}
.alr__ring {
transform: rotate(-90deg);
}
.alr__track {
fill: none;
stroke: rgba(23, 20, 26, 0.12);
stroke-width: 8;
}
.alr__prog {
fill: none;
stroke: #0a4fe8;
stroke-width: 8;
stroke-linecap: round;
transition: stroke-dashoffset 300ms ease-out, stroke 300ms ease-out;
}
.alr--locked .alr__prog {
stroke: #c2361a;
}
.alr__mid {
position: absolute;
display: grid;
justify-items: center;
}
.alr__count {
font: 800 30px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.alr__label {
font: 500 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.6);
}
.alr__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.alr__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.alr__ring {
width: 92px;
height: 92px;
}
.alr__count {
font-size: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.alr__prog {
transition: none;
}
}(function () {
var root = document.getElementById('alr');
var btn = document.getElementById('alrBtn');
var countEl = document.getElementById('alrCount');
var prog = document.getElementById('alrProg');
// 상수: 최대 시도 횟수·링 반지름(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;
// 계산 겹: 링 오프셋 = 둘레 × (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();
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
fail();
});
paint();
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(fail, 380); }
})();import { useEffect, useRef } from "react";
import styles from "./AuthAttemptLockoutRing.module.scss";
type Box<T> = { current: T };
export default function AuthAttemptLockoutRing() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLButtonElement | null;
const countEl = root.querySelector(`.${styles.count}`) as HTMLElement | null;
const prog = root.querySelector(`.${styles.prog}`) as SVGCircleElement | null;
if (!btn || !countEl || !prog) return;
// 상수: 최대 시도 횟수·링 반지름(SVG viewBox 좌표)
const MAX_TRIES = 5;
const R = 40;
const CIRC = 2 * Math.PI * R;
prog.style.strokeDasharray = CIRC.toFixed(2);
let remaining = MAX_TRIES;
// 계산 겹: 링 오프셋 = 둘레 × (1 − 남은/최대) — stroke-dashoffset 산술
function paint() {
countEl!.textContent = String(remaining);
const offset = CIRC * (1 - remaining / MAX_TRIES);
prog!.style.strokeDashoffset = offset.toFixed(2);
root!.classList.toggle(styles.locked, remaining <= 0);
}
function fail() {
if (remaining <= 0) { remaining = MAX_TRIES; paint(); return; }
remaining -= 1;
paint();
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
fail();
}
btn.addEventListener("click", onClick);
paint();
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(fail, 380); }
return () => {
stopAutoplay();
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.alr} ${styles["is-demo"]}`}>
<div className={styles.ringwrap}>
<svg className={styles.ring} viewBox="0 0 100 100" width={120} height={120}>
<circle className={styles.track} cx={50} cy={50} r={40} />
<circle className={styles.prog} cx={50} cy={50} r={40} />
</svg>
<div className={styles.mid}>
<span className={styles.count}>5</span>
<span className={styles.label}>남은 시도</span>
</div>
</div>
<button className={styles.btn} type="button">로그인 시도</button>
</div>
);
}03 · ロックまでの残りリング — 残り試行回数の値を円形の進捗値として表す方式 — 計測 累積面積 2.46% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 定数: 最大試行回数・リング半径(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種類の枠数のどれかにしかなりません。

<main class="stage">
<div class="ace is-demo" id="ace">
<div class="ace__row">
<span class="ace__label">失敗 <b id="aceFails">0</b>回</span>
<span class="ace__stage" id="aceStage">キャプチャなし</span>
</div>
<div class="ace__grid" id="aceGrid"></div>
<button class="ace__btn" id="aceBtn" type="button">ログイン失敗を試す</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 캡차 단계 = floor(실패÷STAGE_THRESHOLD), 칸수 = CELL_STEPS[단계] — index.html 참조
@include stage($stage-ink);
.ace {
width: 100%;
max-width: 300px;
display: grid;
gap: $sp-3;
justify-items: center;
}
.ace__row {
width: 100%;
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
.ace__row b { font-size: 16px; font-variant-numeric: tabular-nums; }
.ace__grid {
display: grid;
grid-template-columns: repeat(var(--cols, 1), 1fr);
gap: $sp-2;
width: 132px;
min-height: 8px;
}
.ace__cell {
aspect-ratio: 1;
border-radius: $r-xs;
background: $subject-lilac;
animation: ace-pop $dur-quick $ease-pop backwards;
}
@keyframes ace-pop {
from { transform: scale(.4); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
.ace__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $subject-cream;
color: $stage-ink;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.ace { max-width: 220px; }
.ace__grid { width: 100px; }
}
@media (prefers-reduced-motion: reduce) {
.ace__btn { transition: none; }
.ace__cell { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ace {
width: 100%;
max-width: 300px;
display: grid;
gap: 12px;
justify-items: center;
}
.ace__row {
width: 100%;
display: flex;
align-items: baseline;
justify-content: space-between;
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.ace__row b {
font-size: 16px;
font-variant-numeric: tabular-nums;
}
.ace__grid {
display: grid;
grid-template-columns: repeat(var(--cols, 1), 1fr);
gap: 8px;
width: 132px;
min-height: 8px;
}
.ace__cell {
aspect-ratio: 1;
border-radius: 4px;
background: #b9a0ff;
animation: ace-pop 220ms cubic-bezier(0.2, 1.4, 0.4, 1) backwards;
}
@keyframes ace-pop {
from {
transform: scale(0.4);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.ace__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #fff7e6;
color: #17141a;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.ace__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.ace {
max-width: 220px;
}
.ace__grid {
width: 100px;
}
}
@media (prefers-reduced-motion: reduce) {
.ace__btn {
transition: none;
}
.ace__cell {
animation: none;
}
}(function () {
var root = document.getElementById('ace');
var btn = document.getElementById('aceBtn');
var failsEl = document.getElementById('aceFails');
var stageEl = document.getElementById('aceStage');
var grid = document.getElementById('aceGrid');
// 상수: キャプチャ 단계 승격 기준 失敗回数 · 단계별 칸 수(제곱수라야 격자가 맞는다) · 자동재생 리셋 주기
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);
}
}
function fail() {
fails += 1;
render();
}
function reset() {
fails = 0;
render();
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
fail();
});
render();
var autoTimer = null;
var tick = 0;
function autoTick() {
tick += 1;
if (tick > AUTOPLAY_RESET_TICKS) { reset(); tick = 0; return; }
fail();
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(autoTick, 300); }
})();import { useEffect, useRef } from "react";
import styles from "./AuthAttemptCaptchaEscalate.module.scss";
type Box<T> = { current: T };
export default function AuthAttemptCaptchaEscalate() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLButtonElement | null;
const failsEl = root.querySelector(`.${styles.fails}`) as HTMLElement | null;
const stageEl = root.querySelector(`.${styles.stagelabel}`) as HTMLElement | null;
const grid = root.querySelector(`.${styles.grid}`) as HTMLElement | null;
if (!btn || !failsEl || !stageEl || !grid) return;
// 상수: 캡차 단계 승격 기준 실패 횟수 · 단계별 칸 수(제곱수라야 격자가 맞는다) · 자동재생 리셋 주기
const STAGE_THRESHOLD = 2;
const CELL_STEPS = [0, 4, 9, 16];
const AUTOPLAY_RESET_TICKS = 7;
let fails = 0;
function render() {
// 계산 겹: 단계 = min(상한, floor(실패÷기준)), 칸수 = CELL_STEPS[단계]
const stage = Math.min(CELL_STEPS.length - 1, Math.floor(fails / STAGE_THRESHOLD));
const cells = CELL_STEPS[stage];
const cols = Math.round(Math.sqrt(cells)) || 0;
failsEl!.textContent = String(fails);
stageEl!.textContent = stage === 0 ? "캡차 없음" : "캡차 " + cols + "×" + cols;
grid!.style.setProperty("--cols", String(cols || 1));
grid!.innerHTML = "";
for (let i = 0; i < cells; i++) {
const c = document.createElement("div");
c.className = styles.cell;
grid!.appendChild(c);
}
}
function fail() { fails += 1; render(); }
function reset() { fails = 0; render(); }
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
fail();
}
btn.addEventListener("click", onClick);
render();
let autoTimer: number | null = null;
let tick = 0;
function autoTick() {
tick += 1;
if (tick > AUTOPLAY_RESET_TICKS) { reset(); tick = 0; return; }
fail();
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 300); }
return () => {
stopAutoplay();
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.ace} ${styles["is-demo"]}`}>
<div className={styles.row}>
<span className={styles.label}>실패 <b className={styles.fails}>0</b>회</span>
<span className={styles.stagelabel}>캡차 없음</span>
</div>
<div className={styles.grid} />
<button className={styles.btn} type="button">로그인 실패 시도</button>
</div>
);
}04 · キャプチャ難易度段階 — 失敗回数がしきい値の値を超えるたびにキャプチャの枠数の値が増える方式 — 計測 累積面積 16.206% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 定数: キャプチャ段階を上げる失敗回数・段階ごとの枠数(グリッドが揃う平方数)・自動再生リセット周期
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 から毎フレーム積分されるため、目標に着いた瞬間に止まらず、少し行き過ぎてから静止します。

<main class="stage">
<div class="abs is-demo" id="abs">
<div class="abs__banner" id="absBanner">ログイン試行が繰り返し検出されました</div>
<div class="abs__card">
<span class="abs__label">繰り返しの失敗 <b id="absFails">0</b>回</span>
<button class="abs__btn" id="absBtn" type="button">ログイン失敗を試す</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 배너 위치를 스프링-감쇠 물리로 적분(강성·감쇠·질량) — index.html 참조
@include stage($stage-yellow);
.abs {
position: relative;
width: 100%;
max-width: 340px;
min-height: 180px;
display: grid;
align-content: end;
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
}
.abs__banner {
position: absolute;
top: 0;
left: 0;
right: 0;
transform: translateY(-60%);
background: $error;
color: $subject-cream;
padding: $sp-3 $sp-4;
font: 700 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
}
.abs__card {
display: grid;
gap: $sp-3;
justify-items: center;
padding: $sp-6 $sp-4 $sp-5;
}
.abs__label {
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.abs__label b { font-size: 16px; font-variant-numeric: tabular-nums; }
.abs__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
@media (max-width: 340px) {
.abs { max-width: 220px; min-height: 150px; }
.abs__banner { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.abs__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.abs {
position: relative;
width: 100%;
max-width: 340px;
min-height: 180px;
display: grid;
align-content: end;
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
}
.abs__banner {
position: absolute;
top: 0;
left: 0;
right: 0;
transform: translateY(-60%);
background: #c2361a;
color: #fff7e6;
padding: 12px 16px;
font: 700 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
}
.abs__card {
display: grid;
gap: 12px;
justify-items: center;
padding: 24px 16px 20px;
}
.abs__label {
font: 600 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.abs__label b {
font-size: 16px;
font-variant-numeric: tabular-nums;
}
.abs__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.abs__btn:active {
transform: scale(0.96);
}
@media (max-width: 340px) {
.abs {
max-width: 220px;
min-height: 150px;
}
.abs__banner {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.abs__btn {
transition: none;
}
}(function () {
var root = document.getElementById('abs');
var btn = document.getElementById('absBtn');
var failsEl = document.getElementById('absFails');
var banner = document.getElementById('absBanner');
// 상수: 배너가 뜨는 失敗 기준 · 스프링 물리(강성·감쇠·질량)
var THRESHOLD = 3;
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·변위 − c·속도)/질량·dt, 위치 += 속도·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) + '%)';
}
}
function spring(to) {
if (rafId) cancelAnimationFrame(rafId);
target = to;
rafId = requestAnimationFrame(function (ts) { tick(ts, ts - 16); });
}
function fail() {
fails += 1;
failsEl.textContent = String(fails);
if (fails === THRESHOLD) spring(0);
}
function reset() {
fails = 0;
failsEl.textContent = '0';
spring(-60);
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
fail();
});
var autoTimer = null;
function autoTick() {
if (fails >= THRESHOLD) { reset(); return; }
fail();
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(autoTick, 420); }
})();import { useEffect, useRef } from "react";
import styles from "./AuthAttemptAlertBannerSlide.module.scss";
type Box<T> = { current: T };
export default function AuthAttemptAlertBannerSlide() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLButtonElement | null;
const failsEl = root.querySelector(`.${styles.fails}`) as HTMLElement | null;
const banner = root.querySelector(`.${styles.banner}`) as HTMLElement | null;
if (!btn || !failsEl || !banner) return;
// 상수: 배너가 뜨는 실패 기준 · 스프링 물리(강성·감쇠·질량)
const THRESHOLD = 3;
const SPRING_K = 180;
const SPRING_C = 18;
const SPRING_M = 1;
let fails = 0;
let pos = -60;
let vel = 0;
let target = -60;
let rafId: number | null = null;
// 계산 겹: 반암시적 오일러 스프링 적분 — 속도 += (-k·변위 − c·속도)/질량·dt, 위치 += 속도·dt
function tick(ts: number, last: number) {
const dt = Math.min(32, ts - last) / 1000;
const disp = pos - target;
const 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((t) => tick(t, ts));
} else {
pos = target;
banner!.style.transform = "translateY(" + pos.toFixed(2) + "%)";
}
}
function spring(to: number) {
if (rafId !== null) cancelAnimationFrame(rafId);
target = to;
rafId = requestAnimationFrame((ts) => tick(ts, ts - 16));
}
function fail() {
fails += 1;
failsEl!.textContent = String(fails);
if (fails === THRESHOLD) spring(0);
}
function reset() {
fails = 0;
failsEl!.textContent = "0";
spring(-60);
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
fail();
}
btn.addEventListener("click", onClick);
let autoTimer: number | null = null;
function autoTick() {
if (fails >= THRESHOLD) { reset(); return; }
fail();
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 420); }
return () => {
stopAutoplay();
if (rafId !== null) cancelAnimationFrame(rafId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.abs} ${styles["is-demo"]}`}>
<div className={styles.banner}>이상 로그인 시도가 반복되었습니다</div>
<div className={styles.card}>
<span className={styles.label}>반복 실패 <b className={styles.fails}>0</b>회</span>
<button className={styles.btn} type="button">로그인 실패 시도</button>
</div>
</div>
);
}05 · 警告バナーのスライド — 繰り返しの失敗がしきい値を超えると、バナーの位置の値が画面上から下りてくる方式 — 計測 累積面積 8.387% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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回しか変わらないため、それで十分だからです。

<main class="stage">
<div class="acc is-demo" id="acc">
<div class="acc__num" id="accNum">8</div>
<div class="acc__msg" id="accMsg">ロック解除までの残り秒数</div>
<button class="acc__btn" id="accBtn" type="button">再試行</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 초가 바뀔 때 easeOutBack 커브로 펄스 스케일 값을 만든다 — index.html 참조
@include stage($stage-paper);
.acc {
display: grid;
justify-items: center;
gap: $sp-2;
}
.acc__num {
font: 800 56px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-blue-deep;
font-variant-numeric: tabular-nums;
transform-origin: center;
}
.acc__msg {
font: 500 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .68);
text-align: center;
}
.acc__btn {
margin-top: $sp-2;
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: opacity $dur-instant $ease-out;
&:disabled { opacity: .4; cursor: not-allowed; }
}
@media (max-width: 340px) {
.acc__num { font-size: 40px; }
}
@media (prefers-reduced-motion: reduce) {
.acc__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.acc {
display: grid;
justify-items: center;
gap: 8px;
}
.acc__num {
font: 800 56px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
transform-origin: center;
}
.acc__msg {
font: 500 13px/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.68);
text-align: center;
}
.acc__btn {
margin-top: 8px;
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 14px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: opacity 140ms ease-out;
}
.acc__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 340px) {
.acc__num {
font-size: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.acc__btn {
transition: none;
}
}(function () {
var root = document.getElementById('acc');
var btn = document.getElementById('accBtn');
var numEl = document.getElementById('accNum');
var msgEl = document.getElementById('accMsg');
// 상수: 잠금 유지 秒 · 데모 안 "1秒"를 표현하는 실제 ms(가속) · 초마다 펄스 스케일 크기
var LOCK_SECONDS = 8;
var DEMO_TICK_MS = 170;
var PULSE_SCALE = 1.22;
var remaining = LOCK_SECONDS;
var timerId = null;
function easeOutBack(t) { return 1 + 1.4 * Math.pow(t - 1, 3) + 0.6 * Math.pow(t - 1, 2); }
// 계산 겹: 초가 바뀔 때 easing 곡선(easeOutBack)으로 스케일 값을 만들어 펄스
function pulse() {
var start = null;
var dur = 160;
function frame(ts) {
if (start === null) start = ts;
var t = Math.min(1, (ts - start) / dur);
var s = 1 + (PULSE_SCALE - 1) * (1 - easeOutBack(t));
numEl.style.transform = 'scale(' + s.toFixed(3) + ')';
if (t < 1) requestAnimationFrame(frame);
else numEl.style.transform = 'scale(1)';
}
requestAnimationFrame(frame);
}
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);
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
if (remaining <= 0) start();
});
paint();
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearTimeout(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
start();
autoTimer = setInterval(function () { if (remaining <= 0) start(); }, DEMO_TICK_MS);
}
})();import { useEffect, useRef } from "react";
import styles from "./AuthAttemptCooldownCountdown.module.scss";
type Box<T> = { current: T };
export default function AuthAttemptCooldownCountdown() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLButtonElement | null;
const numEl = root.querySelector(`.${styles.num}`) as HTMLElement | null;
const msgEl = root.querySelector(`.${styles.msg}`) as HTMLElement | null;
if (!btn || !numEl || !msgEl) return;
// 상수: 잠금 유지 초 · 데모 안 "1초"를 표현하는 실제 ms(가속) · 초마다 펄스 스케일 크기
const LOCK_SECONDS = 8;
const DEMO_TICK_MS = 170;
const PULSE_SCALE = 1.22;
let remaining = LOCK_SECONDS;
let timerId: number | null = null;
function easeOutBack(t: number) { return 1 + 1.4 * Math.pow(t - 1, 3) + 0.6 * Math.pow(t - 1, 2); }
// 계산 겹: 초가 바뀔 때 easing 곡선(easeOutBack)으로 스케일 값을 만들어 펄스
function pulse() {
let start: number | null = null;
const dur = 160;
function frame(ts: number) {
if (start === null) start = ts;
const t = Math.min(1, (ts - start) / dur);
const s = 1 + (PULSE_SCALE - 1) * (1 - easeOutBack(t));
numEl!.style.transform = "scale(" + s.toFixed(3) + ")";
if (t < 1) requestAnimationFrame(frame);
else numEl!.style.transform = "scale(1)";
}
requestAnimationFrame(frame);
}
function paint() {
numEl!.textContent = String(remaining);
root!.classList.toggle(styles.open, remaining <= 0);
msgEl!.textContent = remaining <= 0 ? "지금 다시 시도할 수 있습니다" : "잠금 해제까지 남은 초";
btn!.disabled = remaining > 0;
}
function start() {
remaining = LOCK_SECONDS;
paint();
if (timerId !== null) window.clearInterval(timerId);
timerId = window.setInterval(() => {
remaining -= 1;
pulse();
if (remaining <= 0 && timerId !== null) { window.clearInterval(timerId); timerId = null; }
paint();
}, DEMO_TICK_MS);
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
if (remaining <= 0) start();
}
btn.addEventListener("click", onClick);
paint();
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearTimeout(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
start();
autoTimer = window.setInterval(() => { if (remaining <= 0) start(); }, DEMO_TICK_MS);
}
return () => {
stopAutoplay();
if (timerId !== null) window.clearInterval(timerId);
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.acc} ${styles["is-demo"]}`}>
<div className={styles.num}>8</div>
<div className={styles.msg}>잠금 해제까지 남은 초</div>
<button className={styles.btn} type="button">다시 시도</button>
</div>
);
}06 · ロック解除カウントダウン — ロック後、再試行できるまでの残り秒数の値が減っていく方式 — 計測 累積面積 3.717% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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段階ずつ下がります。下がり幅は各行自身のリスト内順番から出す一定の倍率で、下限を設けているため古い行も完全には消えません。

<main class="stage">
<div class="ahf is-demo" id="ahf">
<button class="ahf__btn" id="ahfBtn" type="button">ログイン試行を追加</button>
<ul class="ahf__list" id="ahfList"></ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: i번째(0=최신) 기록의 불투명도 = max(최소, 1 − i×STEP) — index.html 참조
@include stage($stage-orange);
.ahf {
width: 100%;
max-width: 300px;
display: grid;
gap: $sp-3;
justify-items: center;
}
.ahf__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
.ahf__list {
width: 100%;
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: $sp-2;
}
.ahf__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-2 $sp-3;
border-radius: $r-sm;
background: $subject-cream;
font: 600 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
animation: ahf-in $dur-quick $ease-out backwards;
}
@keyframes ahf-in {
from { transform: translateY(-8px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.ahf__time { color: rgba($stage-ink, .6); }
.ahf__result { color: $stage-ink; }
.ahf__row--fail .ahf__result { color: $error; }
.ahf__row--ok .ahf__result { color: $subject-blue-deep; }
@media (max-width: 340px) {
.ahf { max-width: 220px; }
.ahf__row { font-size: 11px; padding: $sp-2 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ahf__btn { transition: none; }
.ahf__row { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ahf {
width: 100%;
max-width: 300px;
display: grid;
gap: 12px;
justify-items: center;
}
.ahf__btn {
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.ahf__btn:active {
transform: scale(0.96);
}
.ahf__list {
width: 100%;
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.ahf__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 8px;
background: #fff7e6;
font: 600 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
animation: ahf-in 220ms ease-out backwards;
}
@keyframes ahf-in {
from {
transform: translateY(-8px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.ahf__time {
color: rgba(23, 20, 26, 0.6);
}
.ahf__result {
color: #17141a;
}
.ahf__row--fail .ahf__result {
color: #c2361a;
}
.ahf__row--ok .ahf__result {
color: #0a4fe8;
}
@media (max-width: 340px) {
.ahf {
max-width: 220px;
}
.ahf__row {
font-size: 11px;
padding: 8px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ahf__btn {
transition: none;
}
.ahf__row {
animation: none;
}
}(function () {
var root = document.getElementById('ahf');
var btn = document.getElementById('ahfBtn');
var list = document.getElementById('ahfList');
// 상수: 화면에 보이는 최대 기록 수 · 기록 하나마다 낮아지는 투명도 폭 · 최소 투명도
var MAX_ENTRIES = 5;
var OPACITY_STEP = 0.18;
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);
}
}
function addEntry() {
var result = SEQ[seqIdx % SEQ.length];
seqIdx += 1;
entries.unshift({ result: result, time: fmtTime(seqIdx) });
if (entries.length > MAX_ENTRIES) entries.length = MAX_ENTRIES;
render();
}
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
stopAutoplay();
addEntry();
});
render();
var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(addEntry, 340); }
})();import { useEffect, useRef } from "react";
import styles from "./AuthAttemptHistoryListFade.module.scss";
type Box<T> = { current: T };
type Entry = { result: string; time: string };
export default function AuthAttemptHistoryListFade() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btn = root.querySelector(`.${styles.btn}`) as HTMLButtonElement | null;
const list = root.querySelector(`.${styles.list}`) as HTMLElement | null;
if (!btn || !list) return;
// 상수: 화면에 보이는 최대 기록 수 · 기록 하나마다 낮아지는 투명도 폭 · 최소 투명도
const MAX_ENTRIES = 5;
const OPACITY_STEP = 0.18;
const MIN_OPACITY = 0.16;
const SEQ = ["실패", "실패", "성공", "실패", "실패", "실패", "성공", "실패"];
let seqIdx = 0;
let entries: Entry[] = [];
function fmtTime(n: number) {
const m = String(10 + (n % 50)).padStart(2, "0");
return "10:" + m;
}
// 계산 겹: i번째(0=최신) 항목의 불투명도 = max(최소, 1 − i×STEP)
function render() {
list!.innerHTML = "";
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
const op = Math.max(MIN_OPACITY, 1 - i * OPACITY_STEP);
const li = document.createElement("li");
li.className = styles.row + " " + (e.result === "실패" ? styles["row--fail"] : styles["row--ok"]);
li.style.opacity = op.toFixed(2);
li.innerHTML =
'<span class="' + styles.time + '">' + e.time + "</span>" +
'<span class="' + styles.result + '">' + e.result + "</span>";
list!.appendChild(li);
}
}
function addEntry() {
const result = SEQ[seqIdx % SEQ.length];
seqIdx += 1;
entries.unshift({ result, time: fmtTime(seqIdx) });
if (entries.length > MAX_ENTRIES) entries.length = MAX_ENTRIES;
render();
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
addEntry();
}
btn.addEventListener("click", onClick);
render();
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(addEntry, 340); }
return () => {
stopAutoplay();
btn.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.ahf} ${styles["is-demo"]}`}>
<button className={styles.btn} type="button">로그인 시도 추가</button>
<ul className={styles.list} />
</div>
);
}07 · 試行履歴フェードリスト — 新しい試行が積まれるたびに古い記録ほど不透明度の値が下がる方式 — 計測 累積面積 39.983% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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を少し超えてから静止します。

<main class="stage">
<div class="asr is-demo" id="asr">
<div class="asr__readout">
<span class="asr__label">失敗回数</span>
<span class="asr__count" id="asrCount">0</span>
</div>
<div class="asr__row">
<button class="asr__btn asr__btn--fail" id="asrFailBtn" type="button">ログイン失敗</button>
<button class="asr__btn asr__btn--ok" id="asrOkBtn" type="button">ログイン成功</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 성공 시 카운터 스케일을 스프링-감쇠 물리로 적분해 오버슈트 복귀 — index.html 참조
@include stage($stage-yellow);
.asr {
display: grid;
gap: $sp-5;
justify-items: center;
}
.asr__readout {
display: grid;
justify-items: center;
gap: $sp-1;
}
.asr__label {
font: 500 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .64);
}
.asr__count {
font: 800 44px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $error;
font-variant-numeric: tabular-nums;
transform-origin: center;
}
.asr__count--ok { color: $subject-blue-deep; }
.asr__row {
display: flex;
gap: $sp-3;
}
.asr__btn {
border: 0;
border-radius: $r-control;
padding: $sp-2 $sp-3;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
}
.asr__btn--fail { background: $stage-ink; color: $subject-cream; }
.asr__btn--ok { background: $subject-blue-deep; color: $subject-cream; }
@media (max-width: 340px) {
.asr__count { font-size: 34px; }
.asr__row { gap: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.asr__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.asr {
display: grid;
gap: 20px;
justify-items: center;
}
.asr__readout {
display: grid;
justify-items: center;
gap: 4px;
}
.asr__label {
font: 500 12px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.64);
}
.asr__count {
font: 800 44px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #c2361a;
font-variant-numeric: tabular-nums;
transform-origin: center;
}
.asr__count--ok {
color: #0a4fe8;
}
.asr__row {
display: flex;
gap: 12px;
}
.asr__btn {
border: 0;
border-radius: 12px;
padding: 8px 12px;
font: 700 13px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
transition: transform 140ms ease-out;
}
.asr__btn:active {
transform: scale(0.96);
}
.asr__btn--fail {
background: #17141a;
color: #fff7e6;
}
.asr__btn--ok {
background: #0a4fe8;
color: #fff7e6;
}
@media (max-width: 340px) {
.asr__count {
font-size: 34px;
}
.asr__row {
gap: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.asr__btn {
transition: none;
}
}(function () {
var root = document.getElementById('asr');
var failBtn = document.getElementById('asrFailBtn');
var okBtn = document.getElementById('asrOkBtn');
var countEl = document.getElementById('asrCount');
// 상수: 최대 표시 失敗回数 · 리셋 스프링 물리(강성·감쇠·질량)
var MAX_SHOWN = 6;
var SPRING_K = 220;
var SPRING_C = 14;
var SPRING_M = 1;
var fails = 0;
var scalePos = 1;
var scaleVel = 0;
var scaleTarget = 1;
var rafId = null;
function paintCount() {
countEl.textContent = String(fails);
countEl.classList.toggle('asr__count--ok', fails === 0);
}
// 계산 겹: 리셋 순간 스케일 값을 스프링-감쇠 물리로 적분해 오버슈트 복귀를 만든다
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); });
}
function fail() {
fails = Math.min(MAX_SHOWN, fails + 1);
paintCount();
}
function success() {
fails = 0;
paintCount();
bounce();
}
failBtn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); fail(); });
okBtn.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); success(); });
paintCount();
var autoTimer = null;
var tick = 0;
function autoTick() {
tick += 1;
if (tick % 4 === 0) success(); else fail();
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(autoTick, 380); }
})();import { useEffect, useRef } from "react";
import styles from "./AuthAttemptSuccessReset.module.scss";
type Box<T> = { current: T };
export default function AuthAttemptSuccessReset() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const failBtn = root.querySelector(`.${styles["btn--fail"]}`) as HTMLButtonElement | null;
const okBtn = root.querySelector(`.${styles["btn--ok"]}`) as HTMLButtonElement | null;
const countEl = root.querySelector(`.${styles.count}`) as HTMLElement | null;
if (!failBtn || !okBtn || !countEl) return;
// 상수: 최대 표시 실패 횟수 · 리셋 스프링 물리(강성·감쇠·질량)
const MAX_SHOWN = 6;
const SPRING_K = 220;
const SPRING_C = 14;
const SPRING_M = 1;
let fails = 0;
let scalePos = 1;
let scaleVel = 0;
let scaleTarget = 1;
let rafId: number | null = null;
function paintCount() {
countEl!.textContent = String(fails);
countEl!.classList.toggle(styles["count--ok"], fails === 0);
}
// 계산 겹: 리셋 순간 스케일 값을 스프링-감쇠 물리로 적분해 오버슈트 복귀를 만든다
function scaleTick(ts: number, last: number) {
const dt = Math.min(32, ts - last) / 1000;
const disp = scalePos - scaleTarget;
const 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((t) => scaleTick(t, ts));
} else {
scalePos = scaleTarget;
countEl!.style.transform = "scale(1)";
}
}
function bounce() {
if (rafId !== null) cancelAnimationFrame(rafId);
scalePos = 1.5;
scaleVel = 0;
scaleTarget = 1;
rafId = requestAnimationFrame((ts) => scaleTick(ts, ts - 16));
}
function fail() { fails = Math.min(MAX_SHOWN, fails + 1); paintCount(); }
function success() { fails = 0; paintCount(); bounce(); }
function onFailClick() { root!.classList.remove(styles["is-demo"]); stopAutoplay(); fail(); }
function onOkClick() { root!.classList.remove(styles["is-demo"]); stopAutoplay(); success(); }
failBtn.addEventListener("click", onFailClick);
okBtn.addEventListener("click", onOkClick);
paintCount();
let autoTimer: number | null = null;
let tick = 0;
function autoTick() {
tick += 1;
if (tick % 4 === 0) success(); else fail();
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 380); }
return () => {
stopAutoplay();
if (rafId !== null) cancelAnimationFrame(rafId);
failBtn.removeEventListener("click", onFailClick);
okBtn.removeEventListener("click", onOkClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.asr} ${styles["is-demo"]}`}>
<div className={styles.readout}>
<span className={styles.label}>실패 횟수</span>
<span className={styles.count}>0</span>
</div>
<div className={styles.row}>
<button className={`${styles.btn} ${styles["btn--fail"]}`} type="button">로그인 실패</button>
<button className={`${styles.btn} ${styles["btn--ok"]}`} type="button">로그인 성공</button>
</div>
</div>
);
}08 · 成功時リセット — 一度でも成功すると失敗回数の値がすぐに0へ戻る方式 — 計測 累積面積 0.79% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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() でトゥイーンし、右はトゥイーンなしでそのまま数字を書き換えます。

<main class="stage">
<div class="arm is-demo" id="arm">
<div class="arm__panel">
<span class="arm__tag">通常モーション</span>
<span class="arm__count" id="armCountA">5</span>
</div>
<div class="arm__panel">
<span class="arm__tag">モーション削減</span>
<span class="arm__count arm__count--static" id="armCountB">5</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 일반 판은 감쇠 사인 흔들림, 모션 감소 판은 같은 값을 트윈 없이 즉시 반영 — index.html 참조
@include stage($stage-paper);
.arm {
display: flex;
gap: $sp-6;
cursor: pointer;
}
.arm__panel {
display: grid;
justify-items: center;
gap: $sp-2;
padding: $sp-5 $sp-6;
border-radius: $r-card;
background: $subject-cream;
}
.arm__tag {
font: 600 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba($stage-ink, .6);
text-transform: uppercase;
letter-spacing: .04em;
}
.arm__count {
font: 800 40px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.arm { gap: $sp-3; }
.arm__panel { padding: $sp-3 $sp-4; }
.arm__count { font-size: 28px; }
}
@media (prefers-reduced-motion: reduce) {
// 모션 감소 판의 값 갱신은 원래 트윈이 없다 — OS 설정이 켜지면 일반 판도 흔들림 계산을 건너뛴다(index.html)
.arm__count { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.arm {
display: flex;
gap: 24px;
cursor: pointer;
}
.arm__panel {
display: grid;
justify-items: center;
gap: 8px;
padding: 20px 24px;
border-radius: 16px;
background: #fff7e6;
}
.arm__tag {
font: 600 11px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: rgba(23, 20, 26, 0.6);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.arm__count {
font: 800 40px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.arm {
gap: 12px;
}
.arm__panel {
padding: 12px 16px;
}
.arm__count {
font-size: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.arm__count {
transition: none;
}
}(function () {
var root = document.getElementById('arm');
var a = document.getElementById('armCountA');
var b = document.getElementById('armCountB');
// 상수: 최대 시도 횟수 · 通常モーション 흔들림 진폭·지속시간
var MAX_TRIES = 5;
var AMP = 6;
var SHAKE_MS = 300;
var remaining = MAX_TRIES;
// 계산 겹: 일반 판은 감쇠 사인으로 흔들며 값을 바꾸고, モーション削減 판은 같은 값을 애니메이션 없이 즉시 반영한다
function shakeA() {
var start = null;
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 * 3) * (1 - t);
a.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
if (t < 1) requestAnimationFrame(frame);
else a.style.transform = 'translateX(0)';
}
requestAnimationFrame(frame);
}
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); }
})();import { useEffect, useRef } from "react";
import styles from "./AuthAttemptReducedMotionStatic.module.scss";
type Box<T> = { current: T };
export default function AuthAttemptReducedMotionStatic() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement | null>;
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const a = root.querySelector(`.${styles.a}`) as HTMLElement | null;
const b = root.querySelector(`.${styles.b}`) as HTMLElement | null;
if (!a || !b) return;
// 상수: 최대 시도 횟수 · 일반 모션 흔들림 진폭·지속시간
const MAX_TRIES = 5;
const AMP = 6;
const SHAKE_MS = 300;
let remaining = MAX_TRIES;
// 계산 겹: 일반 판은 감쇠 사인으로 흔들며 값을 바꾸고, 모션 감소 판은 같은 값을 애니메이션 없이 즉시 반영한다
function shakeA() {
let start: number | null = null;
function frame(ts: number) {
if (start === null) start = ts;
const t = Math.min(1, (ts - start) / SHAKE_MS);
const x = AMP * Math.sin(t * Math.PI * 2 * 3) * (1 - t);
a!.style.transform = "translateX(" + x.toFixed(2) + "px)";
if (t < 1) requestAnimationFrame(frame);
else a!.style.transform = "translateX(0)";
}
requestAnimationFrame(frame);
}
function step() {
remaining = remaining > 0 ? remaining - 1 : MAX_TRIES;
a!.textContent = String(remaining);
shakeA();
// 모션 감소 판: 같은 값이 트윈 없이 그 자리에서 바로 바뀐다
b!.textContent = String(remaining);
}
function onClick() {
root!.classList.remove(styles["is-demo"]);
stopAutoplay();
step();
}
root.addEventListener("click", onClick);
let autoTimer: number | null = null;
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoTimer = window.setInterval(step, 420);
} else {
autoTimer = window.setInterval(() => {
remaining = remaining > 0 ? remaining - 1 : MAX_TRIES;
a!.textContent = b!.textContent = String(remaining);
}, 420);
}
return () => {
stopAutoplay();
root!.removeEventListener("click", onClick);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.arm} ${styles["is-demo"]}`}>
<div className={styles.panel}>
<span className={styles.tag}>일반 모션</span>
<span className={`${styles.count} ${styles.a}`}>5</span>
</div>
<div className={styles.panel}>
<span className={styles.tag}>모션 감소</span>
<span className={`${styles.count} ${styles["count--static"]} ${styles.b}`}>5</span>
</div>
</div>
);
}09 · モーション削減の即時更新 — モーション削減設定時に値が動きなしで次の状態へ直接変わる方式 — 計測 累積面積 0.868% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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







