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 オリジナル · SCSS と React は zip に同梱 · qr コード アニメーション css 9選 — 残り時間
このデモが属する記事
qr コード アニメーション css 9選 — 残り時間スキャン失敗のせいでもなく、ただ長く浮いているせいでもない — qrコードローディングアニメーションの残り時間の比率の値ひとつが尽きることでリングが狭まり、角度が削られ、終わりに触れると揺れる。3番はその値でパルスリングの半径を狭め、4番は この記事のデモをすべて見る


