01Remaining-tries shake — the remaining-tries value drops and the number shakes briefly each time login fails
Each failed try drops the remaining-count value by one, and at that instant the number shakes left and right briefly.
Best forA regular login form, a payment password confirmation screen
MAX_TRIESAMP_STEPSHAKE_MS

<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="Password" autocomplete="off" spellcheck="false"></label>
</div>
<button class="acs__btn" id="acsBtn" type="button">Log in</button>
<div class="acs__readout">
<span class="acs__label">Tries left</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 — 첫 Fail 때 흔들림 폭
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 Remaining-tries shake — the remaining-tries value drops and the number shakes briefly each time login fails — Measured area 0.505% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Too Many Failed Login Attempts: 9 Patterns
The post this demo lives in
Too Many Failed Login Attempts: 9 PatternsOne value drops by one per failed try, another doubles the wait time as failures pile up, and another counts a remaining See all demos in this post
If you like this effect - similar demos
Auth UI03 Fail-shake decay — on a failed match, the icon shakes left-right along a spring-damping value that settles to zeroFingerprint Scan Animation: 9 Biometric UI Demos
Auth UI01 A Glyph Board Where Each Pixel Column Slides Along a Sine Wave and the Letters BendCSS Captcha: 9 Warped Text and Rotate Checks
Auth UI01 Glassmorphic login card9 Login Form Design Patterns You Can Copy-Paste