05경고 배너 밀림 — 반복 실패가 기준을 넘으면 배너 위치 값이 화면 위에서 내려오는 방식
반복 실패 횟수 값이 기준을 넘는 순간 배너의 위치 값이 화면 밖에서 안으로 이동합니다.
쓰임계정 보안 알림 화면, 이상 로그인 안내 화면
THRESHOLDSPRING_KSPRING_C

<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 원작 · zip 에 SCSS·React 포함 · 로그인 실패 횟수 제한 9종 — 값이 계산으로 좁혀지는 잠금 아홉 가지
이 데모가 속한 글
로그인 실패 횟수 제한 9종 — 값이 계산으로 좁혀지는 잠금 아홉 가지로그인 실패 횟수 제한은 몇 번 틀리면 값이 어떻게 줄고 늘어나는지를 계산으로 보여주는 잠금 인터랙션을 말하는데, 아홉 개 중 하나는 로그인이 틀릴 때마다 남은 시도 횟수 값이 하나씩 줄어들고, 하나는 실패가 쌓일수 이 글의 데모 전부 보기


