08成功時リセット — 一度でも成功すると失敗回数の値がすぐに0へ戻る方式
失敗が積み重なっている途中で一度成功すると、回数の値がすぐ最初の値に戻ります。
使いどころ通常のログイン画面、短時間に何度も試す認証画面
MAX_SHOWNSPRING_KSPRING_C

<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 に同梱 · ログイン 失敗 ロック 9選 — 値が計算で絞られていく9つの方式
このデモが属する記事
ログイン 失敗 ロック 9選 — 値が計算で絞られていく9つの方式9つのうち1つはログインに失敗するたびに残り試行回数の値が1つずつ減り、1つは失敗が重なるほど次の試行までの待ち時間の値が倍々に増え、もう1つはロックが解けるまでの残り秒数の値が0に向かって減っていきます。「ログイン 失敗 ロック」で探して この記事のデモをすべて見る


