08남은시간 값이 0에 닿으면 흔들리는 만료 QR 코드
남은시간 비율 값이 0에 닿는 순간 코드 전체의 흔들림 값이 커졌다 잦아들며, 곧이어 재발급 아이콘이 옅게 나타난다.
쓰임만료된 결제 QR 코드 재발급 유도
EXPIRED_SHAKE_AMPLITUDE_PXshakeOffsetSHAKE_FREQ

<main class="stage">
<div class="esr is-demo" id="esrRoot">
<div class="esr__badge" id="esrBadge">코드 유효</div>
<button class="esr__ctrl" id="esrCtrl" type="button">일시정지</button>
<div class="esr__panel" id="esrPanel">
<div class="esr__code" id="esrCode">QR</div>
<div class="esr__msg" id="esrMsg">스캔해 주세요</div>
<button class="esr__retry" id="esrRetry" type="button">다시 받기</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 만료 시점부터 진동수·지수 감쇠를 곱한 감쇠 사인파로 패널을 좌우로 흔든다 —
// 일정 진폭에서 시작해 자연스럽게 잦아드는 진짜 물리 흔들림이다.
@include stage($stage-ink);
.esr {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.esr__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .08);
color: $subject-cream;
font-weight: 700;
font-size: 11px;
}
.esr__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(255, 247, 230, .12);
color: $subject-cream;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.esr__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.esr__panel {
width: 100%;
max-width: 220px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
padding: $sp-5;
border-radius: $r-card;
background: rgba(255, 255, 255, .05);
box-shadow: $shadow-raised;
will-change: transform;
transition: background-color $dur-quick $ease-out;
&--expired {
background: rgba(255, 100, 100, .08);
}
}
.esr__code {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border-radius: $r-sm;
background: rgba(20, 26, 38, .9);
color: $subject-cream;
font-weight: 800;
font-size: 20px;
transition: color $dur-quick $ease-out;
.esr__panel--expired & {
color: $error;
}
}
.esr__msg {
color: $subject-cream;
font-weight: 600;
font-size: 12px;
text-align: center;
}
.esr__retry {
border: 0;
border-radius: $r-pill;
padding: $sp-1 $sp-4;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity $dur-quick $ease-out;
.esr__panel--expired & {
opacity: 1;
pointer-events: auto;
}
}
.esr__retry:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
@media (max-width: 340px) {
.esr { gap: $sp-1; }
.esr__panel { padding: $sp-2 $sp-3; max-width: 170px; gap: 4px; }
.esr__code { width: 40px; height: 40px; font-size: 13px; }
.esr__msg { font-size: 10px; }
.esr__retry { padding: $sp-1 $sp-3; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.esr__panel { transform: none !important; }
}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);
}
.esr {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.esr__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.esr__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.esr__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.esr__panel {
width: 100%;
max-width: 220px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
will-change: transform;
transition: background-color 220ms ease-out;
}
.esr__panel--expired {
background: rgba(255, 100, 100, 0.08);
}
.esr__code {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border-radius: 8px;
background: rgba(20, 26, 38, 0.9);
color: #fff7e6;
font-weight: 800;
font-size: 20px;
transition: color 220ms ease-out;
}
.esr__panel--expired .esr__code {
color: #c2361a;
}
.esr__msg {
color: #fff7e6;
font-weight: 600;
font-size: 12px;
text-align: center;
}
.esr__retry {
border: 0;
border-radius: 999px;
padding: 4px 16px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 220ms ease-out;
}
.esr__panel--expired .esr__retry {
opacity: 1;
pointer-events: auto;
}
.esr__retry:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.esr {
gap: 4px;
}
.esr__panel {
padding: 8px 12px;
max-width: 170px;
gap: 4px;
}
.esr__code {
width: 40px;
height: 40px;
font-size: 13px;
}
.esr__msg {
font-size: 10px;
}
.esr__retry {
padding: 4px 12px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.esr__panel {
transform: none !important;
}
}(function () {
// EXPIRED_SHAKE_AMPLITUDE_PX — 변형 1: 10 → 16 으로 늘리면 만료 시 흔들림 폭이 커진다.
var EXPIRED_SHAKE_AMPLITUDE_PX = 10;
var LOOP_MS = 2000;
var EXPIRE_AT = 250;
var SHAKE_MS = 700;
var SHAKE_FREQ = 5;
// 시연 스케줄: 2초 루프 안에 만료→흔들림→자동 재시도→만료→흔들림 을 두 번 넣는다.
// RETRY_AT(자동 재시도)·EXPIRE_AT2(두 번째 만료)만 새로 추가 — 진폭·진동수·감쇠 함수는 그대로.
var RETRY_AT = 1050;
var EXPIRE_AT2 = 1300;
var root = document.getElementById('esrRoot');
var badge = document.getElementById('esrBadge');
var ctrl = document.getElementById('esrCtrl');
var panel = document.getElementById('esrPanel');
var codeEl = document.getElementById('esrCode');
var msgEl = document.getElementById('esrMsg');
var retryBtn = document.getElementById('esrRetry');
// 계산 한 겹: 진동수(SHAKE_FREQ)와 지수 감쇠를 곱한 감쇠 사인파로 흔들림을 만든다.
function shakeOffset(t) {
if (t < 0 || t > SHAKE_MS) return 0;
var p = t / SHAKE_MS;
return Math.sin(p * Math.PI * 2 * SHAKE_FREQ) * EXPIRED_SHAKE_AMPLITUDE_PX * Math.exp(-3.2 * p);
}
// 자동 재생에서 만료 구간 판정: 첫 만료(EXPIRE_AT~RETRY_AT) · 두 번째 만료(EXPIRE_AT2~루프 끝) 두 번
function autoExpired(elapsed) {
return (elapsed >= EXPIRE_AT && elapsed < RETRY_AT) || elapsed >= EXPIRE_AT2;
}
function autoExpireStart(elapsed) {
return elapsed < RETRY_AT ? EXPIRE_AT : EXPIRE_AT2;
}
var manualRetryAt = -1;
function render(elapsed) {
var expired = manualRetryAt < 0 && autoExpired(elapsed);
var t = manualRetryAt >= 0 ? elapsed - manualRetryAt : elapsed - autoExpireStart(elapsed);
var offset = expired ? shakeOffset(t) : 0;
panel.style.transform = offset ? 'translateX(' + offset.toFixed(2) + 'px)' : 'none';
// 계산 한 겹: 같은 감쇠 사인파(shakeOffset)의 크기를 그림자·배경 톤·QR 격자 흐림에 그대로 전파한다.
var normOffset = Math.min(1, Math.abs(offset) / EXPIRED_SHAKE_AMPLITUDE_PX);
panel.style.boxShadow = '0 14px 24px rgba(23, 20, 26, .32), 0 0 ' +
(normOffset * 20).toFixed(1) + 'px rgba(233, 64, 64, ' + (normOffset * 0.55).toFixed(2) + ')';
codeEl.style.filter = normOffset > 0.02 ? 'blur(' + (normOffset * 1.2).toFixed(2) + 'px)' : 'none';
if (expired) {
panel.classList.add('esr__panel--expired');
panel.style.backgroundColor = 'rgba(255, 100, 100, ' + (0.08 + normOffset * 0.10).toFixed(3) + ')';
codeEl.textContent = '×';
msgEl.textContent = '코드가 만료됐어요';
badge.textContent = '코드 만료';
} else {
panel.classList.remove('esr__panel--expired');
panel.style.backgroundColor = '';
codeEl.textContent = 'QR';
msgEl.textContent = '스캔해 주세요';
badge.textContent = '코드 유효';
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
var elapsed = reduce ? EXPIRE_AT + SHAKE_MS : 0;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
}
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) { elapsed -= LOOP_MS; manualRetryAt = -1; }
}
render(elapsed);
requestAnimationFrame(tick);
}
render(elapsed);
requestAnimationFrame(tick);
retryBtn.addEventListener('click', function () {
manualRetryAt = elapsed;
});
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? '일시정지' : '재생';
});
ctrl.addEventListener('keydown', function (e) {
if ((e.key === 'r' || e.key === 'R') && !reduce) {
elapsed = 0;
manualRetryAt = -1;
playing = true;
ctrl.textContent = '일시정지';
}
});
})();import { useEffect, useRef, useState } from "react";
import styles from "./ExpiredShakeRetry.module.scss";
export default function ExpiredShakeRetry() {
const rootRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const ctrlRef = useRef<HTMLButtonElement>(null);
const retryRef = useRef<HTMLButtonElement>(null);
const [badge, setBadge] = useState("코드 유효");
const [ctrlLabel, setCtrlLabel] = useState("일시정지");
const [code, setCode] = useState("QR");
const [msg, setMsg] = useState("스캔해 주세요");
const [expired, setExpired] = useState(false);
useEffect(() => {
const root = rootRef.current;
const panel = panelRef.current;
const ctrl = ctrlRef.current;
const retryBtn = retryRef.current;
if (!root || !panel || !ctrl || !retryBtn) return;
// EXPIRED_SHAKE_AMPLITUDE_PX — 변형 1: 10 → 16 으로 늘리면 만료 시 흔들림 폭이 커진다.
const EXPIRED_SHAKE_AMPLITUDE_PX = 10;
const LOOP_MS = 2000;
const EXPIRE_AT = 250;
const SHAKE_MS = 700;
const SHAKE_FREQ = 5;
// 시연 스케줄: 2초 루프 안에 만료→흔들림→자동 재시도→만료→흔들림 을 두 번 넣는다.
// RETRY_AT(자동 재시도)·EXPIRE_AT2(두 번째 만료)만 새로 추가 — 진폭·진동수·감쇠 함수는 그대로.
const RETRY_AT = 1050;
const EXPIRE_AT2 = 1300;
// 계산 한 겹: 진동수(SHAKE_FREQ)와 지수 감쇠를 곱한 감쇠 사인파로 흔들림을 만든다.
function shakeOffset(t: number) {
if (t < 0 || t > SHAKE_MS) return 0;
const p = t / SHAKE_MS;
return Math.sin(p * Math.PI * 2 * SHAKE_FREQ) * EXPIRED_SHAKE_AMPLITUDE_PX * Math.exp(-3.2 * p);
}
// 자동 재생에서 만료 구간 판정: 첫 만료(EXPIRE_AT~RETRY_AT) · 두 번째 만료(EXPIRE_AT2~루프 끝) 두 번
function autoExpired(elapsed: number) {
return (elapsed >= EXPIRE_AT && elapsed < RETRY_AT) || elapsed >= EXPIRE_AT2;
}
function autoExpireStart(elapsed: number) {
return elapsed < RETRY_AT ? EXPIRE_AT : EXPIRE_AT2;
}
let manualRetryAt = -1;
function render(elapsed: number) {
const isExpired = manualRetryAt < 0 && autoExpired(elapsed);
const t = manualRetryAt >= 0 ? elapsed - manualRetryAt : elapsed - autoExpireStart(elapsed);
const offset = isExpired ? shakeOffset(t) : 0;
panel!.style.transform = offset ? "translateX(" + offset.toFixed(2) + "px)" : "none";
// 계산 한 겹: 같은 감쇠 사인파(shakeOffset)의 크기를 그림자·배경 톤·QR 격자 흐림에 그대로 전파한다.
const normOffset = Math.min(1, Math.abs(offset) / EXPIRED_SHAKE_AMPLITUDE_PX);
panel!.style.boxShadow = "0 14px 24px rgba(23, 20, 26, .32), 0 0 " +
(normOffset * 20).toFixed(1) + "px rgba(233, 64, 64, " + (normOffset * 0.55).toFixed(2) + ")";
if (isExpired) {
setExpired(true);
setCode("×");
setMsg("코드가 만료됐어요");
setBadge("코드 만료");
} else {
setExpired(false);
setCode("QR");
setMsg("스캔해 주세요");
setBadge("코드 유효");
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
let elapsed = reduce ? EXPIRE_AT + SHAKE_MS : 0;
if (reduce) {
playing = false;
root.classList.remove("is-demo");
}
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) { elapsed -= LOOP_MS; manualRetryAt = -1; }
}
render(elapsed);
raf = requestAnimationFrame(tick);
}
render(elapsed);
raf = requestAnimationFrame(tick);
function onRetry() {
manualRetryAt = elapsed;
}
function onClick() {
playing = !playing;
setCtrlLabel(playing ? "일시정지" : "재생");
}
function onKeydown(e: KeyboardEvent) {
if ((e.key === "r" || e.key === "R") && !reduce) {
elapsed = 0;
manualRetryAt = -1;
playing = true;
setCtrlLabel("일시정지");
}
}
retryBtn.addEventListener("click", onRetry);
ctrl.addEventListener("click", onClick);
ctrl.addEventListener("keydown", onKeydown);
return () => {
cancelAnimationFrame(raf);
retryBtn.removeEventListener("click", onRetry);
ctrl.removeEventListener("click", onClick);
ctrl.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.esr} is-demo`} ref={rootRef}>
<div className={styles.esr__badge}>{badge}</div>
<button className={styles.esr__ctrl} type="button" ref={ctrlRef}>{ctrlLabel}</button>
<div
className={`${styles.esr__panel}${expired ? " " + styles["esr__panel--expired"] : ""}`}
ref={panelRef}
>
<div className={styles.esr__code}>{code}</div>
<div className={styles.esr__msg}>{msg}</div>
<button className={styles.esr__retry} type="button" ref={retryRef}>다시 받기</button>
</div>
</div>
</main>
);
}08 남은시간 값이 0에 닿으면 흔들리는 만료 QR 코드 — 측정 누적면적 32.133% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · qr 코드 만료 카운트다운 css 애니메이션 9가지
이 데모가 속한 글
qr 코드 만료 카운트다운 css 애니메이션 9가지qr 코드 만료 표시는 값 하나로 아홉 장면을 만든다. 스캔 실패 때문도 아니고 그냥 오래 떠 있어서도 아니다 — qr 코드 만료까지 남은시간 비율 값 하나가 사라져야 링이 좁아지고, 각도가 깎이고, 끝에 닿으면 흔 이 글의 데모 전부 보기


